Selaa lähdekoodia

#42 Add suppor for client-side search (#227)

* Add support for client-side search with Fuse.js. Implements #42.

Based on https://gist.github.com/eddiewebb/735feb48f50f0ddd65ae5606a1cb41ae

* Move search form code repeated in many places to one single template.

* Add search box in home page.

* Fix search UI + Some improvements

Signed-off-by: hossainemruz <hossainemruz@gmail.com>

* Remove search.md from exampleSite

Signed-off-by: hossainemruz <hossainemruz@gmail.com>

Co-authored-by: Emruz Hossain <hossainemruz@gmail.com>
Chuso Pérez 4 vuotta sitten
vanhempi
commit
0848be17ba

+ 52 - 0
content/posts/search.md

@@ -0,0 +1,52 @@
+---
+title: "Search Results"
+sitemap:
+  priority : 0.1
+layout: "search"
+url: search
+---
+
+
+This file exists solely to respond to /search URL with the related `search` layout template.
+
+No content shown here is rendered, all content is based in the template layouts/page/search.html
+
+Setting a very low sitemap priority will tell search engines this is not important content.
+
+This implementation uses Fusejs, jquery and mark.js
+
+
+## Initial setup
+
+Search  depends on additional output content type of JSON in config.toml
+\```
+[outputs]
+  home = ["HTML", "JSON"]
+\```
+
+## Searching additional fileds
+
+To search additional fields defined in front matter, you must add it in 2 places.
+
+### Edit layouts/_default/index.JSON
+This exposes the values in /index.json
+i.e. add `category`
+\```
+...
+  "contents":{{ .Content | plainify | jsonify }}
+  {{ if .Params.tags }},
+  "tags":{{ .Params.tags | jsonify }}{{end}},
+  "categories" : {{ .Params.categories | jsonify }},
+...
+\```
+
+### Edit fuse.js options to Search
+`static/js/search.js`
+\```
+keys: [
+  "title",
+  "contents",
+  "tags",
+  "categories"
+]
+\```

+ 8 - 0
exampleSite/config.yaml

@@ -32,6 +32,14 @@ languages:
     languageName: 中文
     weight: 8
 
+# At least HTML and JSON are required for the main HTML content and
+# client-side JavaScript search
+outputs:
+  home:
+    - HTML
+    - RSS
+    - JSON
+
 # Force a locale to be use, really useful to develop the application ! Should be commented in production, the "weight" should rocks.
 # DefaultContentLanguage: fr
 

+ 5 - 0
layouts/_default/index.json

@@ -0,0 +1,5 @@
+{{- $.Scratch.Add "index" slice -}}
+{{- range .Site.RegularPages -}}
+    {{- $.Scratch.Add "index" (dict "title" .Title "hero" (partial "helpers/get-hero.html" .) "date" (.Date.Format "January 2, 2006") "summary" .Summary "tags" .Params.tags "categories" .Params.categories "contents" .Plain "permalink" .Permalink) -}}
+{{- end -}}
+{{- $.Scratch.Get "index" | jsonify -}}

+ 3 - 1
layouts/_default/list.html

@@ -16,7 +16,9 @@
   <section class="sidebar-section" id="sidebar-section">
     <div class="sidebar-holder">
       <div class="sidebar" id="sidebar">
-        <input type="text" value="" placeholder="Search" data-search="" id="search-box" />
+        <form class="mx-auto" method="get" action="{{ "search" | absURL }}">
+          <input type="text" name="keyword" value="" placeholder="Search" data-search="" id="search-box" />
+        </form>
         <div class="sidebar-tree">
           <ul class="tree" id="tree">
             <li id="list-heading"><a href="{{ .Type | relLangURL }}" data-filter="all">{{ i18n .Type }}</a></li>

+ 71 - 0
layouts/_default/search.html

@@ -0,0 +1,71 @@
+{{ define "header" }}
+    <link rel="stylesheet" href="{{ "/css/layouts/list.css" | relURL }}">
+    <link rel="stylesheet" href="{{ "/css/navigators/sidebar.css" | relURL}}">
+{{ end }}
+
+{{ define "navbar" }}
+    {{ partial "navigators/navbar-2.html" . }}
+{{ end }}
+
+{{ define "sidebar" }}
+  {{ $blogHome:="#" }}
+  {{ if site.IsMultiLingual }}
+    {{ $blogHome = (path.Join (cond ( eq .Language.Lang "en") "" .Language.Lang) .Type) }}
+  {{ end }}
+
+  <section class="sidebar-section" id="sidebar-section">
+    <div class="sidebar-holder">
+      <div class="sidebar" id="sidebar">
+        <form class="mx-auto" method="get" action="{{ "search" | absURL }}">
+          <input type="text" name="keyword" value="" placeholder="Search" data-search="" id="search-box" />
+        </form>
+        <div class="sidebar-tree">
+          <ul class="tree" id="tree">
+            <li id="list-heading"><a href="{{ .Type | relLangURL }}" data-filter="all">{{ i18n .Type }}</a></li>
+            <div class="subtree">
+                {{ partial "navigators/sidebar.html" (dict "menus" site.Menus.sidebar "ctx" .) }}
+            </div>
+          </ul>
+        </div>
+      </div>
+    </div>
+  </section>
+{{ end }}
+
+{{ define "content" }}
+<section class="content-section" id="content-section">
+  <div class="content container-fluid" id="content">
+    <div class="container-fluid post-card-holder" id="post-card-holder">
+      <div id="search-results">
+
+        <script id="search-result-template" type="text/x-js-template">
+          <div class="post-card">
+            <a href="${link}" class="post-card-link">
+              <div class="card" style="min-height: 352px;"><a href="${link}" class="post-card-link">
+                <div class="card-head">
+                  <img class="card-img-top" src="${hero}">
+                </div>
+                <div class="card-body">
+                  <h5 class="card-title">${title}</h5>
+                  <p class="card-text post-summary">${summary}</p>
+                </div>
+                <div class="card-footer">
+                  <span class="float-left">${date}</span>
+                  <a href="${link}" class="float-right btn btn-outline-info btn-sm">Read</a>
+                </div>
+              </div>
+	          </a>
+          </div>
+        </script>
+
+      </div>
+    </div>
+  </div>
+</section>
+{{ end }}
+
+{{ define "scripts" }}
+<script src="https://cdnjs.cloudflare.com/ajax/libs/fuse.js/3.2.0/fuse.min.js"></script>
+<script src="https://cdnjs.cloudflare.com/ajax/libs/mark.js/8.11.1/jquery.mark.min.js"></script>
+<script src="{{ "/js/search.js" | absURL }}"></script>
+{{ end }}

+ 3 - 1
layouts/_default/single.html

@@ -16,7 +16,9 @@
   <section class="sidebar-section" id="sidebar-section">
     <div class="sidebar-holder">
       <div class="sidebar" id="sidebar">
-        <input type="text" value="" placeholder="Search" data-search="" id="search-box" />
+        <form class="mx-auto" method="get" action="{{ "search" | absURL }}">
+          <input type="text" name="keyword" value="" placeholder="Search" data-search="" id="search-box" />
+        </form>
         <div class="sidebar-tree">
           <ul class="tree" id="tree">
             <li id="list-heading"><a href="{{ "/posts" | relLangURL }}" data-filter="all">{{ i18n "posts" }}</a></li>

+ 112 - 0
static/js/search.js

@@ -0,0 +1,112 @@
+summaryInclude=60;
+var fuseOptions = {
+  shouldSort: true,
+  includeMatches: true,
+  threshold: 0.0,
+  tokenize:true,
+  location: 0,
+  distance: 100,
+  maxPatternLength: 32,
+  minMatchCharLength: 1,
+  keys: [
+    {name:"title",weight:0.8},
+    {name:"hero",weight:0.7},
+    {name:"summary",weight:0.6},
+    {name:"date",weight:0.5},
+    {name:"contents",weight:0.5},
+    {name:"tags",weight:0.3},
+    {name:"categories",weight:0.3}
+  ]
+};
+
+
+var searchQuery = param("keyword");
+if(searchQuery){
+  $("#search-query").val(searchQuery);
+  executeSearch(searchQuery);
+}else {
+  $('#search-results').append("<p>Please enter a word or phrase above</p>");
+}
+
+
+
+function executeSearch(searchQuery){
+  $.getJSON( "/index.json", function( data ) {
+    var pages = data;
+    var fuse = new Fuse(pages, fuseOptions);
+    var result = fuse.search(searchQuery);
+    // console.log({"matches":result});
+    document.getElementById("search-box").value = searchQuery
+    if(result.length > 0){
+      populateResults(result);
+    }else{
+      $('#search-results').append("<p>No matches found</p>");
+    }
+  });
+}
+
+function populateResults(result){
+  $.each(result,function(key,value){
+    var contents= value.item.contents;
+    var snippet = "";
+    var snippetHighlights=[];
+    var tags =[];
+    if( fuseOptions.tokenize ){
+      snippetHighlights.push(searchQuery);
+    }else{
+      $.each(value.matches,function(matchKey,mvalue){
+        if(mvalue.key == "tags" || mvalue.key == "categories" ){
+          snippetHighlights.push(mvalue.value);
+        }else if(mvalue.key == "contents"){
+          start = mvalue.indices[0][0]-summaryInclude>0?mvalue.indices[0][0]-summaryInclude:0;
+          end = mvalue.indices[0][1]+summaryInclude<contents.length?mvalue.indices[0][1]+summaryInclude:contents.length;
+          snippet += contents.substring(start,end);
+          snippetHighlights.push(mvalue.value.substring(mvalue.indices[0][0],mvalue.indices[0][1]-mvalue.indices[0][0]+1));
+        }
+      });
+    }
+
+    if(snippet.length<1){
+      snippet += contents.substring(0,summaryInclude*2);
+    }
+    //pull template from hugo templarte definition
+    var templateDefinition = $('#search-result-template').html();
+    //replace values
+    var output = render(templateDefinition,{key:key,title:value.item.title,hero:value.item.hero,date:value.item.date,summary:value.item.summary,link:value.item.permalink,tags:value.item.tags,categories:value.item.categories,snippet:snippet});
+    $('#search-results').append(output);
+
+    $.each(snippetHighlights,function(snipkey,snipvalue){
+      $("#summary-"+key).mark(snipvalue);
+    });
+
+  });
+}
+
+function param(name) {
+    return decodeURIComponent((location.search.split(name + '=')[1] || '').split('&')[0]).replace(/\+/g, ' ');
+}
+
+function render(templateString, data) {
+  var 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
+  var key, find, re;
+  for (key in data) {
+    find = '\\$\\{\\s*' + key + '\\s*\\}';
+    re = new RegExp(find, 'g');
+    templateString = templateString.replace(re, data[key]);
+  }
+  return templateString;
+}