A statikus weboldalak (avagy a Jamstack) esetén előre meg kell fogalmazni a form-okkal és a kereséssel kapcsolatos elképzeléseket; tulajdonképp akkor ideálisak az alkalmazási körülmények, ha ilyenekre nem lesz szükség. Viszont kisebb oldalak teljes tartalma elfér egy pár száz KiB, legfeljebb néhány MiB méretű JSON-fájlban, és ekkor a keresést kliensoldalon is megvalósíthatjuk saját backend vagy külső API nélkül.
Az ismertetett megoldás a Hugo in Action könyv1 és Tom Witkowski bejegyzése2 alapján készült. A terv röviden: minden oldal szövegét (és egyéb adatait) beletesszük egy JSON fájlba, a keresőlap megnyitásakor ezt a kliensünk letölti és indexszé alakítja. Ha felhasználói input érkezik a keresőmezőből, azzal keres az indexben, majd megjeleníti a találatokat.
JSON-alapú pszeudo-API
A kliensoldali indexhez szükséges adatokat Hugóval generáljuk, ehhez hozzuk létre a layouts/_default/index.json
template-et:
{{- $result := newScratch -}}
{{- $result.Set "pages" slice -}}
{{- range site.RegularPages -}}
{{- $result.Add "pages"
(dict "url" .RelPermalink "content" .Plain "title" .Title "tags" .Params.Tags) -}}
{{- end -}}
{{- $result.Get "pages" | jsonify -}}
Érdemes lehet a lapok szövegéből eltávolítani a lényegtelen szavakat. Ezekre egyrészt nincs szükség kereséskor, másrészt ezáltal nem válik túl könnyen fogyaszthatóvá a JSON API tartalomaggregátorok számára. (Mint kiderült, a re2 nem működik jól unicode karakterekkel. Angol szövegekhez megfelelne egy replaceRE filter a fenti template-ben, ehelyett egy külön perl scriptet iktattam be a publikálás elé.)
Szerkesszük a nyitólapi front mattert (content/_index.md
), ezzel generáláskor elkészül majd az index.json.
outputs: [html, json, rss]
JS a Hugo-projektben
Hozzuk létre az assets/js/index.js
fájlt, egyelőre üresen. Így állítjuk elő a végleges JS-t Hugo pipes-szal:
{{- $defines := dict "BASE_URL" (print `"` site.BaseURL `"`) -}}
{{- $opts := dict "targetPath" "js/app.js" "minify" hugo.IsProduction "defines" $defines -}}
{{- $js := resources.Get "js/index.js" | js.Build $opts | fingerprint -}}
<script defer src="{{ $js.Permalink }}"></script>
Az npm package.json
konfigurációja így néz ki induláskor, egyelőre nincsenek JS-függőségeink:
{
"private": true,
"scripts": {
"dev": "hugo server -D",
"prod": "hugo && npm run deploy",
"clean": "rm -rf public/*",
"deploy": "rsync -az --delete public/ myserver.hu:/path/"
},
"dependencies": {
"bulma": "^0.8.0"
}
}
Indexelés és keresés: Fuse.js
Telepítsük a függőségeinket:
$ npm i fuse.js alpinejs
A Fuse.js egyszerű és stabil modul további függőségek nélkül. Hozzunk létre egy assets/js/search.js
modult, amelyben lekérdezzük az adatokat az API-tól és elkészítjük az indexet:
import Fuse from 'fuse.js'
const MAX_RESULT_NUM = 5;
const MIN_QUERY_LEN = 3;
export default {
index: null,
query: "",
results: [],
async init() {
return fetch(BASE_URL + "index.json")
.then(response => response.json())
.then(items => {
this.index = new Fuse(items, {
threshold: 0.2,
ignoreLocation: true,
includeScore: true,
minMatchCharLength: MIN_QUERY_LEN,
keys: [
{
name: 'title',
weight: 20
},
{
name: 'tag',
weight: 5
},
{
name: 'content'
}
]
});
})
.catch(console.error);
},
find() {
if (this.index === null || this.query.length < MIN_QUERY_LEN) {
this.results = [];
return false;
}
this.results = this.index
.search(this.query)
.slice(0, MAX_RESULT_NUM);
},
}
Rövid és áttekinthető a kód. Importáljuk ezt a modult az index.js-ben:
import Search from "./search"
import Alpine from 'alpinejs'
window.Alpine = Alpine
window.Search = Search
Alpine.start()
DOM-manipulálás: Alpine.js
Használhatnánk Vue.js-t, de az igényeinknek tökéletesen megfelel a pehelysúlyú Alpine.js is. Tegyünk valami ilyesmit a keresőoldal template-jébe (nem dropdown-t csináltam, ami a minden oldalon elérhető keresőmezőhöz kéne, de az is hasonló lenne):
<div x-data="window.Search">
<p>
<input placeholder="keresés..."
type="search"
name="search"
x-model="query"
x-on:input.debounce.300ms="find"
autocomplete="off" />
</p>
<ul x-bind:class="{'hidden': results.length == 0}">
<template x-for="result in results">
<li>
<a x-bind:href="result.item.url">
<span x-text="result.score.toFixed(2)"></span>
<span x-text="result.item.title"></span>
</a>
</li>
</template>
</ul>
</div>
Ennyi az egész, itt gyakorlatilag egyetlen sor JS-t sem írtunk. Ha ügyelünk a weboldal elemeinek méretére (amit helyesen teszünk), akkor azt állapíthatjuk meg, hogy behúzunk minden lapon 63 KiB JS-t, a JSON viszont csak a keresőoldalon töltődik le.
Összességében elmondható, hogy meglepően egyszerűen elértük a célunkat. Első ránézésre csak egy hack-nek tűnhet maga a kliensoldali keresés, de valódi megoldásnak bizonyult, ha tisztában vagyunk a korlátaival.
-
Jain, Atishay: Hugo in Action. Static sites and dynamic Jamstack apps. Manning, 2022. ↩︎
-
Tom Witkowski: Static search with Fuse.js. gummibeer.dev, 2021-01-09. ↩︎