js  

Preface

Goal: Create a native javascript search feature, for static site generator. Without lunrjs or such.

I have been wondering, on how to create internal search, for use with my static blog. I was enable to create for my own use in 2018, but I still wonder how to make the code more portable, without any third party javascript.

This search is based on JSON file artefact created as static content. You can create this JSON file by any SSG (static site generator).


The Issue

All I need is courage.

You might have seen my article about utilizing lunrjs to create internal search engine in Hugo.

That article was written in 2018. It still have hard dependency with jQuery. I intent to get rid of both lunrjs and jQuery, by using native javascript.

Two years has past, and I still can’t get it done. The issue is I simply do not have time to do it. I’m not that brave.

This 2020, my friend wrote local article about the same issue, and publish the article to local hugo group.

This article contain the material that I need.


The JSON Format

Array

First the JSON format. We need to filter this JSON with javascript, which only works with array.

The JSON should be in array format, not the hash (associative array). The difference is as below:

array

[
    {
      ...
    },
    {
      ...
    },
]

associative array

{
    "something": {
      ...
    },
    "something else": {
      ...
    },
}

Example Fields

We are going to need a JSON with format similar to this below:

[
    {
      "title": "Jerry Maguire",
      "content": "You had me at Hello.",
      "url": "/quotes/2015/01/01/jerry-maguire/",
      "author": "epsi",
      "category": "quotes"
    },
    {
      "title": "Scott Pilgrim vs The World",
      "content": "Did you know that the original name for Pac-Man was Puck-Man?",
      "url": "/quotes/2015/01/01/scott-pigrim-vs-the-world/",
      "author": "epsi",
      "category": "quotes"
    },
    {
      "title": "Fight Club",
      "content": "You are not special.",
      "url": "/quotes/2015/01/01/fight-club/",
      "author": "epsi",
      "category": "quotes"
    },
    {
      "title": "Dead Poet Society",
      "content": "So avoid using the word very because it's lazy.",
      "url": "/quotes/2015/01/01/dead-poets-society/",
      "author": "epsi",
      "category": "quotes"
    },
]

You are free to use any field.

Hugo: JSON Content Type


Generating JSON

You can generate JSON with any SSG (static site generator).

Hugo

Hugo is so ready to jsonify. In contrast with other SSG.

{{ define "main" }}
{{- $posts := where .Site.Pages "Type" "post" -}}
{{- $postCount := len $posts -}}
[
  {{ range $i, $e := $posts }}
    {
      "title": {{ jsonify $e.Title }},
      "content": {{ jsonify ($e.Params.excerpt | default $e.Summary) }},
      "url": {{ jsonify .RelPermalink }},
      "author": {{ jsonify $e.Params.author }},
      "category": {{ jsonify $e.Section  }}
    }
    {{- if not (eq (add $i 1) $postCount) }},{{ end -}}
  {{ end }}
]
{{ end }}

11ty

---
permalink: pages/index.json
---

{%- set posts = collections.posts -%}
{%- set postCount = posts.length -%}
{%- set cursor = 1 -%}

[
  {% for post in posts %}
    {
      "title": "{{ post.data.title }}",
      "excerpt": "{{ post.data.excerpt }}",
      "url":   "{{ post.url }}",
      "date": "{{ post.date | date('YYMMDDmm') }}"
    }
    {%-if cursor != postCount %},{% endif -%}
    {%-set cursor = cursor + 1 %}
  {% endfor %}
]

Jekyll

---
layout: null
---
[
  {% for post in site.posts %}

    {
      "title": "{{ post.title | json }}",
      "url": "{{ post.url | xml_escape }}"
    }
    {% unless forloop.last %},{% endunless %}
  {% endfor %}
]

Pelican

[
  {% for article in articles %}
    {
      "title": "{{ article.title }}",
      "url": "/{{ article.url }}"
    }{{ "," if not loop.last }}
  {% endfor %}
]

Hexo

[
<%
  var count = site.posts.length
  var i = 0;
  site.posts.each(function(post){
    i++
_%>
  {
    "id": <%= date(post.date, "YYMMDDmm") %>,
    "title": "<%= post.title %>",
    "url": "<%= post.path %>"
  }<% if (i!=count){%>,<% } %>
<% }) _%>
]

Start Coding in NodeJS

It is easier to test in command line interface, rather than in browser.

Consider using NodeJS to start coding. We are going to move it to frontend browser, after we get done.

Dependencies

Since nodejs do not have fetch function, we need to add node-fetch first.

$ npm i node-fetch --save

Fetch JSON

The most important part is the async await fetch.

const fetch = require("node-fetch");

const url  = 'http://localhost:1313/pages/archives/index.json'

const getArchivesJSON = async () => {
    let response = await fetch(url)
    let data = await response.json()
    return data
}

getArchivesJSON()
    .then(data => console.log(data) )
    .catch(reason => console.log(reason.message))

This script is using ES2015 for the fat Arrow function. And using ES2017 for the async await stuff.

Node: Search: async await fetch

This will print a complete JSON.

Filter JSON

The next thing to do is to filter the array against a search keyword. For example I query an archive post, that includes the word You in content field.

const fetch = require("node-fetch");

const query = "You"
const url  = 'http://localhost:1313/pages/archives/index.json'

const getArchivesJSON = async () => {
    let response = await fetch(url)
    let data = await response.json()
    return data
}

function checkQuery(item) {
  return item.content.includes(query)
}

function processJSON(dataJSON) {
  filteredJSON = dataJSON.filter(checkQuery)
  console.log(filteredJSON)
}

getArchivesJSON()
    .then(data => processJSON(data) )
    .catch(reason => console.log(reason.message))

You can change the field content, to anything that match your JSON structure.

function checkQuery(item) {
  return item.content.includes(query)
}

Node: Search: filter includes

This will print a filtered JSON.

Simplified Code

Now you can get cryptic with javascript. And also add necessary comments.

const fetch = require("node-fetch");

const searchQuery = "You"
const url = 'http://localhost:1313/pages/archives/index.json'

// Get the posts lists in json format.
 const getArchivesJSON = async () => {
    let response = await fetch(url)
    let data = await response.json()
    return data
}

// trigger async function
// log response or catch error of fetch promise
getArchivesJSON()
    .then(data => {
       filtered = data.filter(
         item => item.content.includes(searchQuery)
       )
       console.log(filtered)
     })
    .catch(reason => console.log(reason.message))

You can see the original article here:

Node: Search: Simplified Code with Fat Arrow

This will print the same result as previous.


The HTML Form

Main Search Form

The HTML form is simple.

<form action="get" id="search_site">
  <label for="search_box">Search</label>
  <input type="text" id="search_box" name="query">
  <input type="submit" value="search">
</form>

<ul id="search_results" class="list-unstyled"></ul>

We need to add a few ids, so we can manipulate them later.

  • id: search_site
  • id: search_box
  • id: search_results

Main Search Form

We also want to enable URL query such as:

  • http://localhost:1313/pages/search/?q=You

This way, we can have a form in navbar in every pages, taht will redirect the result to main search form. The mavbar form is as below:

      <div class="navbar-end">
        <form class="is-marginless" action="/pages/search/" method="get">
        <div class="navbar-item">
          <input class="" type="text" name="q"
            placeholder="Search..." aria-label="Search">
          &nbsp;
          <button class="button is-light" 
            type="submit">Search</button>
        </div>
        </form>
      </div>

Additional Search Form in Navigation Bar


Query in Browser

I intent to create native javascript, without jquery or any framework.

Conider create a javacript file with any name, such as search-native.js or anything you want, and load it with your SSG.

Simple Script

The code in script will only be activated, after page has completely loaded.

document.addEventListener("DOMContentLoaded", function(event) { 
  ...
});

Getting URL Parameter

Let’s say, we have a query from other page

  • http://localhost:1313/pages/search/?q=You
document.addEventListener("DOMContentLoaded", function(event) { 
  // DOM stuff
  const searchBox = document.getElementById("search_box");

  // Get search results if q parameter is set in querystring
  const urlParams = new URLSearchParams(window.location.search);
  if (urlParams.get('q')) {
      let paramQuery = decodeURIComponent(urlParams.get('q'))
      searchBox.value = paramQuery
  }
});

Now, whenever you click the URL, the searchBox will also contain the You text.

The Submit Event

This form works based on submit event. So we need to propagate by making a trigger, then listen the onSubmit event.

document.addEventListener("DOMContentLoaded", function(event) { 
  // DOM stuff
  const searchBox     = document.getElementById("search_box");
  const searchSite    = document.getElementById("search_site");

  // Event when the form is submitted
  searchSite.addEventListener("submit", (submitEvent) => {
    submitEvent.preventDefault()

    // Get the value for the text field
    searchQuery = searchBox.value
    console.log(searchQuery)
  }); 

  // Get search results if q parameter is set in querystring
  const urlParams = new URLSearchParams(window.location.search);
  if (urlParams.get('q')) {
      let paramQuery = decodeURIComponent(urlParams.get('q'))
      searchBox.value = paramQuery

      // Trigger submit event
      let event = document.createEvent('HTMLEvents');
      event.initEvent('submit', true, true);
      searchSite.dispatchEvent(event);
  }
});

You can test the result in console log.

Search Form: Trigger Submit Event

Fetch JSON

Consider combine our previous JSON fetch with the script in browser.

document.addEventListener("DOMContentLoaded", function(event) { 
  // DOM stuff
  const searchBox     = document.getElementById("search_box");
  const searchSite    = document.getElementById("search_site");
  const searchResults = document.getElementById("search_results");

  // URL of the data from the JSON file we generated
  const url  = '/pages/archives/index.json'
  
  // Get the posts lists in json format.
  const getArchivesJSON = async () => {
    let response = await fetch(url)
    let data = await response.json()
    return data
  }

  function displaySearchResults(items) {
    console.log(items)
  }

  // Event when the form is submitted
  searchSite.addEventListener("submit", (submitEvent) => {
    submitEvent.preventDefault()

    // Get the value for the text field
    searchQuery = searchBox.value

    // trigger async function
    // log response or catch error of fetch promise
    getArchivesJSON()
      .then(data => {
         // Perform a search to an array
         filtered = data.filter(
           item => item.content.includes(searchQuery)
         )

         // Hand the results off to be displayed
         displaySearchResults(filtered);
       })
      .catch(reason => console.log(reason.message))
  }); 

  // Get search results if q parameter is set in querystring
  ...
});

Search Form: Fetching JSON

Notice this line below:

  // URL of the data from the JSON file we generated
  const url  = '/pages/archives/index.json'

We do not use localhost:1313 anymore for live site.

Display Search Result

Finally, we have to display the JSON fetch in good manners.

  function displaySearchResults(items) {
    // Are there any results?
    if (items.length) {
      // Clear any old results
      while(searchResults.firstChild)
        searchResults.removeChild(searchResults.firstChild)

      // Iterate over the results
      items.forEach((item) => {
        // Build a snippet of HTML for this result
        // Then, add it to the results
        searchResults.innerHTML += '<li><a href="'
          + item.url + '">' + item.title + '</a></li>'
      });
    } else {
      searchResults.innerHTML = '<li>No results found</li>';
    }
  }

Search Form: Display Search Result


Conclusion

No need jQuery or lunr.

As a conclusion, here is the complete code.

// This script part is inspired by
// https://nurofsun.github.io/membuat-fitur-pencarian-hugo/

document.addEventListener("DOMContentLoaded", function(event) { 
  // DOM stuff
  const searchBox     = document.getElementById("search_box");
  const searchSite    = document.getElementById("search_site");
  const searchResults = document.getElementById("search_results");

  // URL of the data from the JSON file we generated
  const url  = '/pages/archives/index.json'
  
  // Get the posts lists in json format.
  const getArchivesJSON = async () => {
    let response = await fetch(url)
    let data = await response.json()
    return data
  }

  function displaySearchResults(items) {
    // Are there any results?
    if (items.length) {
      // Clear any old results
      while(searchResults.firstChild)
        searchResults.removeChild(searchResults.firstChild)

      // Iterate over the results
      items.forEach((item) => {
        // Build a snippet of HTML for this result
        // Then, add it to the results
        searchResults.innerHTML += '<li><a href="'
          + item.url + '">' + item.title + '</a></li>'
      });
    } else {
      searchResults.innerHTML = '<li>No results found</li>';
    }
  }

  // Event when the form is submitted
  searchSite.addEventListener("submit", (submitEvent) => {
    submitEvent.preventDefault()

    // Get the value for the text field
    searchQuery = searchBox.value

    // trigger async function
    // log response or catch error of fetch promise
    getArchivesJSON()
      .then(data => {
         // Perform a search to an array
         filtered = data.filter(
           item => item.content.includes(searchQuery)
         )

         // Hand the results off to be displayed
         displaySearchResults(filtered);
       })
      .catch(reason => console.log(reason.message))
  }); 

  // Get search results if q parameter is set in querystring
  const urlParams = new URLSearchParams(window.location.search);
  if (urlParams.get('q')) {
      let paramQuery = decodeURIComponent(urlParams.get('q'))
      searchBox.value = paramQuery

      // Trigger submit event
      let event = document.createEvent('HTMLEvents');
      event.initEvent('submit', true, true);
      searchSite.dispatchEvent(event);
  }
});

Farewell. We shall meet again.