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.
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.
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)
}
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:
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 id
s,
so we can manipulate them later.
- id:
search_site
- id:
search_box
- id:
search_results
Navbar 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">
<button class="button is-light"
type="submit">Search</button>
</div>
</form>
</div>
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
.
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
...
});
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>';
}
}
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.