26 November 2023

Menambahkan Fitur Search di Hugo

Hugo Coding
Menambahkan fitur search di Hugo

Fitur search atau pencarian merupakan salah satu elemen penting dalam sebuah website. Fitur ini memungkinkan pengunjung untuk mencari artikel yang ingin mereka baca. Selain memudahkan mereka, fitur pencarian juga bermanfaat untuk meningkatkan pengalaman pengguna.

Lalu, bagaimana caranya menambahkan fitur search di Hugo?

Meskipun Hugo adalah Static-Site Generator (SSG) yang secara default tidak memungkinkan untuk fungsi pencarian, kita bisa “mengakali” itu dengan menggunakan library JavaScript dari pihak ketiga. Di artikel ini, kita akan sama-sama membuat fitur pencarian di Hugo menggunakan library Fuse.js dan Mark.js.


Mengapa Fuse.js?

Ada beberapa alasan mengapa saya memutuskan untuk menggunakan Fuse.js alih-alih library lainnya.

Fuse.js adalah sebuah library JavaScript untuk fuzzy searching. Fuzzy searching adalah sebuah metode pencarian untuk menemukan string yang kurang lebih sama dengan pola tertentu. Pencarian Fuse.js dilakukan di sisi klien (client-side) atau di browser sehingga tidak memerlukan database. Hal ini tentu kompatibel dengan Hugo yang memang tidak memiliki backend.

Selain itu, Fuse.js juga ringan dan tanpa dependensi. Bahkan, kita bisa menggunakan Fuse.js dengan atau tanpa jQuery.

Bagaimana cara kerjanya? Kita akan membuat file index.json yang men-generate semua konten web kita ke dalam file json. File json inilah yang digunakan Fuse.js sebagai “database” untuk mencari konten berdasarkan query yang diketikkan pengguna lalu menampilkan hasilnya di search result.


Cara Menambahkan Fitur Search di Hugo dengan Fuse.js dan Mark.js

Sebagai bayangan, kita akan membuat fitur pencarian yang menghasilkan search result seperti ini.

Search result

Langkah-langkah dan sintaks saya sadur dari artikel Add Search to a Hugo Site dengan beberapa penyesuaian. Seperti misalnya, di artikel tersebut hasil pencariannya tidak ada gambar atau featured image, saya menambahkan featured image agar tampilan hasil pencarian mirip dengan tampilan post index di blog ini.

Di artikel tersebut, Edd juga masih menggunakan Fuse.js v.6.2.2, sedangkan saya menggantinya ke versi paling baru ketika artikel ini ditulis yaitu v.7.0.0.

Oh ya, saya menggunakan vanilla JavaScript. Untuk Anda yang menggunakan jQuery, bisa membaca dokumentasi di Gist . Jangan lupa untuk menggunakan Fuse.js dan Mark.js versi terbaru.

Langkah pertama adalah membuat form untuk pencarian. Letakkan di mana pun Anda ingin form pencarian itu ditampilkan. Secara sederhana, form-nya adalah seperti ini:

<form action="/search" method="GET">
  <input type="search" name="q" id="search-query" placeholder="Search...." />
  <button type="submit">Search</button>
</form>

Anda bisa menambahkan class pada form, input, atau button untuk mengatur tampilan. Bisa juga mengganti button dengan ikon. Tetapi ada beberapa hal yang perlu diperhatikan dan harus persis sama:

  • action="/search" ==> atribut action di sini mengacu pada halaman search.html yang nanti akan kita buat.
  • method="GET" ==> method-nya get.
  • name="q" ==> params.
  • id="search-query" ==> id ini akan kita gunakan untuk inisialisasi di JavaScript.

2. Page: /content/search/_index.md

Buat folder search di content. Kemudian di dalamnya buat file _index.md. Direktori lengkapnya: content/search/_index.md.

Di dalam file _index.md tambahkan script berikut:

---
title: "Search"
sitemap:
  priority: 0.1
layout: "search"
---

Kita tak perlu menambahkan apa-apa lagi di _index.md. Tampilan hasil pencarian akan di-handle oleh file search.html yang akan kita buat di langkah ketiga.

3. Layout: /layouts/_default/search.html

Buat file search.html di layouts/_default/search.html. Anda bisa juga membuatnya di dalam folder themes sehingga direktorinya menjadi themes/layouts/_default/search.html. Halaman inilah yang nanti akan menangani hasil pencarian.

Masukkan script berikut:

<!-- layouts/_default/search.html -->
{{ define "main" }}
<div>
  <h1 id="search-heading">Search Result for</h1>

  <!-- Hasil pencarian akan ditampilkan di sini -->
  <div id="search-results"></div>
  <div class="search-loading">Loading...</div>

  <!-- Template untuk mengatur tampilan hasil pencarian -->
  <script id="search-result-template" type="text/x-js-template">
    <article id="summary-${key}">
    <div><img src="${featured_image}" alt="${title}"></div>
    <div>
    	<h2><a href="${link}" class="absolute-link">${title}</a></h2>
    	<p>${snippet}</p>
    	<small>
    			${ isset tags }Tags: ${tags}<br>${ end }
    	</small>
    </div>
    </article>
  </script>
</div>

<!-- File JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/fuse.js@7.0.0"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mark.js/8.11.1/mark.min.js"></script>
<script src="/js/search.js"></script>
{{ end }}

Saya sengaja menghilangkan class dari semua elemen, Anda bebas menambahkan class untuk mengatur tampilan dengan CSS agar sesuai dengan blog Anda. Yang terpenting id dalam elemen yang nanti akan kita gunakan di JavaScript. Anda juga bisa memindahkan <script> ke lokasi lain, misalnya sebelum </body>.

4. JavaScript: static/js/search.js

Buat file JavaScript dengan nama file search.js di folder static.

const summaryInclude = 100;
const fuseOptions = {
  shouldSort: true,
  includeMatches: false,
  findAllMatches: false,
  includeScore: true,
  tokenize: true,
  location: 0,
  ignoreLocation: false,
  threshold: 0.3,
  distance: 1000,
  maxPatternLength: 32,
  minMatchCharLength: 2,
  keys: [
    { name: "title", weight: 1 },
    { name: "contents", weight: 0.1 },
    { name: "tags", weight: 0.1 },
    { name: "categories", weight: 0.05 },
  ],
};

// =============================
// Search
// =============================

const inputBox = document.getElementById("search-query");

if (inputBox !== null) {
  let searchQuery = param("q");

  if (searchQuery) {
    inputBox.value = searchQuery || "";
    executeSearch(searchQuery, false);
  } else {
    const searchHeading = document.getElementById("search-heading");
    const searchResults = document.getElementById("search-results");

    // Pemeriksaan keberadaan elemen sebelum mengubah propertinya
    if (searchHeading !== null) {
      searchHeading.innerHTML = `No Results Found`;
    }

    if (searchResults !== null) {
      searchResults.innerHTML = `<p class="font--center search-result-empty">Please enter a word or phrase in the search form.</p>`;
    }
  }

  hide(document.querySelector(".search-loading"));
}

function executeSearch(searchQuery) {
  show(document.querySelector(".search-loading"));

  fetch("/index.json").then(function (response) {
    if (response.status !== 200) {
      console.log(
        "Looks like there was a problem. Status Code: " + response.status,
      );
      return;
    }
    // Examine the text in the response
    response
      .json()
      .then(function (pages) {
        const fuse = new Fuse(pages, fuseOptions);
        const result = fuse.search(searchQuery);
        if (result.length > 0) {
          document.getElementById("search-heading").innerHTML =
            `Search Result for "${searchQuery}"`;
          populateResults(result);
        } else {
          document.getElementById("search-heading").innerHTML =
            `Search Result for "${searchQuery}"`;
          document.getElementById("search-results").innerHTML =
            '<p class="font--center search-result-empty">No matches found</p>';
        }
        hide(document.querySelector(".search-loading"));
      })
      .catch(function (err) {
        console.log("Fetch Error :-S", err);
      });
  });
}

function populateResults(results) {
  const searchQuery = document.getElementById("search-query").value;
  const searchResults = document.getElementById("search-results");

  // pull template from hugo template definition
  const templateDefinition = document.getElementById(
    "search-result-template",
  ).innerHTML;

  results.forEach(function (value, key) {
    const contents = value.item.contents;
    let snippet = "";
    const snippetHighlights = [];

    snippetHighlights.push(searchQuery);
    snippet = contents.substring(0, summaryInclude * 2) + "&hellip;";

    //replace values
    let tags = "";
    if (value.item.tags) {
      value.item.tags.forEach(function (element) {
        const encodedTag = element.toLowerCase().replace(/ /g, "-");
        tags =
          tags +
          "<a href='/blog/tags/" +
          encodedTag +
          "'>" +
          "#" +
          element +
          "</a> ";
      });
    }

    const output = render(templateDefinition, {
      key: key,
      title: value.item.title,
      link: value.item.permalink,
      tags: tags,
      categories: value.item.categories,
      snippet: snippet,
      featured_image: value.item.featured_image,
    });
    searchResults.innerHTML += output;

    snippetHighlights.forEach(function (snipvalue, snipkey) {
      const instance = new Mark(document.getElementById("summary-" + key));
      instance.mark(snipvalue);
    });
  });
}

function render(templateString, data) {
  let conditionalMatches, conditionalPattern, copy;
  conditionalPattern = /\$\{\s*isset ([a-zA-Z]*) \s*\}(.*)\$\{\s*end\s*}/g;
  //since loop below depends on re.lastInxdex, we use a copy to capture any manipulations whilst inside the loop
  copy = templateString;
  while (
    (conditionalMatches = conditionalPattern.exec(templateString)) !== null
  ) {
    if (data[conditionalMatches[1]]) {
      //valid key, remove conditionals, leave contents.
      copy = copy.replace(conditionalMatches[0], conditionalMatches[2]);
    } else {
      //not valid, remove entire section
      copy = copy.replace(conditionalMatches[0], "");
    }
  }
  templateString = copy;
  //now any conditionals removed we can do simple substitution
  let key, find, re;
  for (key in data) {
    find = "\\$\\{\\s*" + key + "\\s*\\}";
    re = new RegExp(find, "g");
    templateString = templateString.replace(re, data[key]);
  }
  return templateString;
}

// Helper Functions
function show(elem) {
  if (elem) {
    elem.style.display = "block";
  }
}

function hide(elem) {
  if (elem) {
    elem.style.display = "none";
  }
}

function param(name) {
  return decodeURIComponent(
    (location.search.split(name + "=")[1] || "").split("&")[0],
  ).replace(/\+/g, " ");
}

Apabila Anda ingin memodifikasi const fuseOptions lebih jauh, bisa baca-baca di dokumentasi Fuse.js .

5. Json: layouts/_default/index.json

Sekarang, kita buat file index.json di /layouts/_default/index.json. Seperti yang saya sampaikan sebelumnya, file inilah yang akan berfungsi sebagai “database”. Masukkan kode berikut.

{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
    {{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "categories" .Params.categories "contents" .Plain "permalink" .Permalink "featured_image" .Params.featured_image) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

6. Config: hugo.toml

Langkah berikutnya adalah menambahkan [outputs] di file konfigurasi. Buka file hugo.toml lalu tambahkan script berikut.

[outputs]
    home = ["HTML", "RSS", "JSON"]

Apabila Anda masih menggunakan Hugo di bawah v.0.110.0, barangkali Anda masih menggunakan file config.toml. Saya kira ini waktunya untuk melakukan update Hugo agar konfigurasinya lebih update.


Konflik dengan Cusdis

Karena saya menggunakan sistem komentar Cusdis , terjadi konflik dengan pesan eror “Uncaught SyntaxError: Identifier ’e’ has already been declared”. Itu karena Cusdis dan Fuse.js sama-sama menggunakan variabel e.

Meskipun saya berusaha sebisa mungkin menggunakan block scope, entah kenapa eror tetap terjadi. Solusinya adalah mengganti nama variabel di Cusdis. Bila Anda tidak menggunakan Cusdis atau JavaScript lain yang menggunakan nama variabel sama, saya kira eror ini tidak akan terjadi.


Yak, selesai! Silakan tes fitur search yang telah Anda buat. Semoga tidak ada eror, ya. Apabila Anda mengalami kendala atau punya saran tambahan, silakan tuliskan di kolom komentar. (eL)

S H A R E:

Langit Amaravati

Langit Amaravati

Web developer, graphic designer, techno blogger.

Aktivis ngoding barbar yang punya love-hate relationship dengan JavaScript. Hobi mendengarkan lagu dangdut koplo dan lagu campursari. Jika tidak sedang ngoding dan melayout buku, biasanya Langit melukis, belajar bahasa pemrograman baru, atau meracau di Twitter.

Komentar