Hugo search engine
Introduzione⌗
Ho realizzato questo semplice progetto giusto per aumentare le mie skils e aquisire maggiore esperienza con Hugo, che è il motore che genera anche questo sito, e javascript di cui non mi sono mai occupato troppo, cogliendo l’occasione di una richiesta di aiuto, e dopo qulche ricerca infruttuosa, ho deciso di intraprendere questa esperienza.
Il progetto è abbastanza semplice, come dicevo, si tratta di uno script in js che fa uso della libreria fuse.js, rilsciata sotto licenza Apache, e di qualche file ad uso di Hugo, ma partiamo subito con lo SPIEGONE.
File⌗
Come dicevamo ci sono solo una manciata di file da creare ed, eventualmente, da modificare e sono questi:
static/js/fuse.js
Libreria per le ricerce, scaricabile dal sito ufficiale.static/js/search.js
Script js per elaborare la ricerca e mostrarla.layouts/_default/index.json
file per generare il file JSON con i dati.layouts/_default/baseof.html
File per mostrare il form di ricerca.config.toml
Il file di configurazione di Hugarchetypes/default.md
Il file dove definire il Front Matter dei posts.
Di seguito spieghero la struttura dei principali file di questo progetto
static/js/search.js⌗
Ci sono diversi metodi per implementare questo tipo di funzionalitè, io mi sono avvalso della libreria fuse.js perchè l’ho trovata leggera e senza dipendenze. Il fulcro di tutto il progetto, oltre ovviamente alla sopra cita libreria, è lo script search.js, che ho scritto e che si occupa di prendere la richiesta, inviata dal form di ricerca, e che a sua volta la invierà a fuse.js che, con l’ausilio del file JSON, la elaborerà e la ritornerà allo script che in fine lamostrerà all’utente.
Ho diviso il file in sezioni in modo da poter entrare un pò più nel dettaglio del funzionamento dello script.
Variabili⌗
// Opzioni per la ricerca di fuse
const options = {
shouldSort: true,
location: 0,
distance: 100,
threshold: 0.4,
minMatchCharLength: 2,
// Chiavi di ricerca
keys: [
'title',
'permalink',
'tags',
'categories'
]
};
const hidenTime = 15000; // Tempo di attesa prima che i risultati mostrati spariscano
const n = 5; // Numero massimo di risultati da mostrare
const contentLenght = 50; // Lunghezza del contenuto dei post riportati dalla ricerca
const fileJSON = 'index.json'; // JSON da cui raccogliere i dati, va inserto il percorso completo escluso il baseurl (es. https://www.miosito.it/sito/index.json => /sito/index.json )
// Elementi del DOM
const searchInput = document.getElementById("searchInput"); // Campo di input per la query di ricerca
const searchButton = document.getElementById("searchButton"); // Bottone di invio query
const searchResult = document.getElementById("searchResults"); // Elemento padre che conterra' la lista dei risultati
Qui troviamo tutte le variabili, la prima options
sono tutte le opzioni passate
alla libreria fuse.js comprese le chiavi (keys) che gli dicono quali sono le
sezioni in cui dovrà effettuare la ricerca.
Del tipo: vogliamo che la ricerca sia effettuata solo nei tags e nei titoli dei
posts allora metteremo solo quelle due voci nella lista, ovviamente nel file
JSON che Hugo produrrà dovranno esserci quelle chiavi.
Tutti gli altri valori sono, abbastanza, autoesplicativi, c’è la variabile che
da il tempo per il quale i risultati vengono mostrati, il numero massimo di
risultati da mostrare, la lunghezza del testo di anteprima dei risultati, il
file JSON contenente i dati ed in fine tutti gli elementi del DOM.
Event listener⌗
// Event listener che controlla se nel campo inputSearch e' stato premuto RET
searchInput.addEventListener('keyup', (event) => { if(event.key == "Enter") executeSearch(); });
// Event listener che controlla se il bottone searchButton e' stato premuto
searchButton.addEventListener('click', (event) => { if(event.button == 0) executeSearch() });
Anche per questa sezione non ci sono molte cose da dire ed è autoesplicativa, il primo Event Listener si occupa di catturare la pressione del tasto RETURN, quando ci si trova nel campo di ricerca, per poi far partire la funzione di ricerca, analogamente il secondo si occupa di catturare il click del mouse sul bottone di ricerca.
Funzioni⌗
// funzione che elabora il file JSON senza l'utilizzo di jquery
function fetchJSONFile(path, callback) {
let httpRequest = new XMLHttpRequest();
httpRequest.onreadystatechange = function() {
if (httpRequest.readyState === 4) {
if (httpRequest.status === 200) {
let data = JSON.parse(httpRequest.responseText);
if (callback) callback(data);
}
}
};
httpRequest.open('GET', path);
httpRequest.send();
}
Questa prima funzione non fa altro che fare una richiesta per recuperare i dati dal file JSON.
Questa funzione che è il cuore di tutto il processo l’ho ulteriormente suddivisa per spiegare meglio il funzionamento
// Funzione che esegue la query di ricerca inserita nel campo searchInput
function executeSearch() {
// Se il campo searchInput non e' vuoto
if(searchInput.value !== '') {
// Elabora il file JSON e restituisce un risultato
fetchJSONFile(fileJSON, function(data){
let fuse = new Fuse(data, options); // inizializza fuse
let results = fuse.search(searchInput.value); // Variabile con i valori della ricerca con la ricerca passatagli
let searchItems = ''; // Variabile che conterra' il risultato, elaborato, da mostrare
Prima di tutto valutiamo se è stato inserito un qualcosa nel campo di ricerca, in caso contrario non verrà elaborato nulla ed il processo terminerà, altrimenti si procede con il richiamare la funzione, che abbiamo visto poco sopra e che si occupa di recuperare i dati JSON, poi si inizializza l’oggetto fuse a cui si passano i dati su cui eseguire la ricerca e le opzioni, comprese le chiavi, di come eseguire la ricerca ed in fine si assegna, la lista di risultati generata dalla funzione search di fuse.js, alla varianbile results.
// Se ci sono risultati
if(results.length > 0){
// Cicla n risultati
for (let i in results.slice(0, n)) {
let result = results[i].item // Variabile di appoggio del singolo risultato
// Concatena i risultati elaborati nella variabile seachItems
searchItems = searchItems + '<li><a href="' + result.permalink + '" tabindex="0">' + '<span class="title">' + result.title + '</span><br /><span class="sc">'+ result.tags +' — ' + result.date + '</span><br /><span class="content">' + result.contents.slice(0, contentLenght) + '</span></a></li>';
}
} else { // Se non ci sono risultati
searchitems = "<p>No matches found</p>";
}
Adesso abbiamo i nostri risultati che con un semplice ciclo for
verranno
mostrati a schermo, in caso non avessimo risultati verà mostrato un messaggio
per informarci della mancanza di risultati.
// Fa partire il timer per nascondere i risultati
setTimeout(() => {
searchResult.innerHTML = ''; // Elimina i risultati
}, hidenTime);
Questa metodo richiama una funzione, dopo un dato intervallo, nello specifico ripulisce i risultati dopo il tempo che abbiamo inpostato inizialmente.
// Mostra i risultati
searchResult.innerHTML = searchItems; // Mostra i risultati
searchInput.value = '' // Svuota il campo searchInput
});
}
}
In ultima istanza mostriamo i risultati e svuotiamo il campo di ricerca.
layouts/_default/index.json⌗
Questo file, scritto in go, dice a Hugo qual’è la struttura ed i dati che deve avere il file JSON che dovrà generare, sempre partendo dai file che si trovano nella cartella content.
Non sono realmente consapevole di tutto il contenuto del file, sò, però, che la parte che interessa a noi è quella centrale che inizia dove vengono definiti le chiavi ed i dati che devono finire nel dizionario, il nostro file JSON. e sono:
- title -> Che prende il titolo del post
- tags -> Che prende la lista dei tags del nostro post
- categories -> Che prende la lista delle categorie del nostro post
- contents -> Che prende lintero contenuto del nostro post
- permalink -> Che prende il link al post
{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
{{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "categories" .Params.categories "contents" .Plain "permalink" .Permalink "date" .Params.date) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}
Tutti i dati e le chiavi contribuiscono nel generare il file JSON che dovrebbe apparire simile a questo mostrato di seguito.
[
{
"categories":null,
"contents":"BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA ",
"date":"2023-02-17T11:21:01+01:00",
"permalink":"https://www.canaliluca.com/posts/BLA_1/",
"tags":["tag1","tag2","tag3","tag4"],
"title":"Titolo del post BLA1"
},
{
"categories":["cat1","cat2"],
"contents":"BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA BLA ",
"date":"2023-01-28T00:00:00+01:00",
"permalink":"https://www.canaliluca.com/posts/BLA_2/",
"tags":["tag1","tag2"],
"title":"Titolo del post BLA2"
}
]
layouts/_default/baseof.html⌗
Questo file mostra il nostro form di ricerca, l’ho recuperato da quello fornito dal tema che ho scelto, in questo caso Terminal, aggiungendo la parte centrale che in poche parole controlla se ci troviamo nella home page ed in caso positivo mostra il form di ricerca, qui grazie alle funzionalità di Hugo ho potuto usare sia parti di go che parti di html.
<!DOCTYPE html>
<html lang="{{ $.Site.Language }}">
<head>
{{ block "title" . }}
<title>{{ if .IsHome }}{{ $.Site.Title }}{{ else }}{{ .Title }} :: {{ $.Site.Title }}{{ end }}</title>
{{ end }}
{{ partial "head.html" . }}
</head>
<body class="{{- ( or .Params.color $.Site.Params.ThemeColor ) -}}">
{{ $container := cond ($.Site.Params.FullWidthTheme | default false) "container full" (cond ($.Site.Params.CenterTheme | default false) "container center" "container") }}
<div class="{{- $container -}}{{- cond ($.Site.Params.oneHeadingSize | default true) " headings--one-size" "" }}">
{{ partial "header.html" . }}
<!-- Show search bar only at home page -->
{{ if .IsHome }}
<!-- Search input -->
<div id="search" style="margin-left: 10px;margin-top: 20px;">
<input id="searchInput" tabindex="0">
<button id="searchButton">Search</button>
<ul id="searchResults">
</ul>
</div>
<script src="js/fuse.js"></script> <!-- download and copy over fuse.js file from fusejs.io -->
<script src="js/search.js"></script>
{{ end }}
<div class="content">
{{ block "main" . }}
{{ end }}
</div>
{{ block "footer" . }}
{{ partial "footer.html" . }}
{{ end }}
</div>
</body>
</html>
config.toml⌗
Questo è il file di configurazione di Hugo in cui dobbiamo specificare che vogliamo che esporti i dati anche in JSON e lo facciamo aggiungento questo modulo.
[outputs]
home = ["HTML", "RSS", "JSON"]
Il linguaggio usato di default è toml ma ci sono anche altri formati supportati
archetypes/default.md⌗
Assolutamente facoltativo ma anche vivamente consigliato, questo file dice a
Hugo, quando si usa il comando Hugo new posts/first_post.md
, di generare un
nuovo contenuto di tipo post nominato first_post.md e che troveremo nella
cartella posts all’interno della cartella content. Questa cosa è molto utile
perchè il file così creato conterrà il Front Matter in parte compilato da Hugo
con data e titolo e tutto quello che vorremmo preimpostare.
---
title: 'Titolo del post'
date: 2023-02-17T19:13:47+01:00
draft: true
tags:
- ''
- ''
categories:
- ''
- ''
---
Conclusioni⌗
Come ho detto nell’introduzione, il progetto è abbastanza semplice, basta copiare i file che trovate nel mio github e, forse, personalizzarne solo alcuni, comunque spero che il progetto sia utile a qualcuno e se riscontrate problemi vi prego di segnalermeli.