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.


  1. Jain, Atishay: Hugo in Action. Static sites and dynamic Jamstack apps. Manning, 2022. ↩︎

  2. Tom Witkowski: Static search with Fuse.js. gummibeer.dev, 2021-01-09. ↩︎