Przeglądaj źródła

Add built-in search engine

Complement existing integration of Algolia search engine with
alternative option of built-in search engine based on Fuse.

Setting `engine = 1` in `config.toml` will enable the built-in solution.

Close #635
George Cushen 7 lat temu
rodzic
commit
a11d8843bb

+ 8 - 0
data/assets.toml

@@ -47,6 +47,14 @@
   version = "3.2.5"
   sri = "sha256-X5PoE3KU5l+JcX+w09p/wHl9AzK333C4hJ2I9S5mD4M="
   url = "https://cdnjs.cloudflare.com/ajax/libs/fancybox/%s/jquery.fancybox.min.js"
+[js.fuse]
+  version = "3.2.1"
+  sri = "sha256-VzgmKYmhsGNNN4Ph1kMW+BjoYJM2jV5i4IlFoeZA9XI="
+  url = "https://cdnjs.cloudflare.com/ajax/libs/fuse.js/%s/fuse.min.js"
+[js.mark]
+  version = "8.11.1"
+  sri = "sha256-4HLtjeVgH0eIB3aZ9mLYF6E8oU5chNdjU6p6rrXpl9U="
+  url = "https://cdnjs.cloudflare.com/ajax/libs/mark.js/%s/jquery.mark.min.js"
 [js.instantsearch]
   version = "2.9.0"
   sri = "sha256-cJXigylnJlxvAdfFNHeS+IiMcKWS3Rf/cy3bl9bb0ng="

+ 3 - 2
exampleSite/config.toml

@@ -306,8 +306,9 @@ enableGitInfo = false
 [params.search]
   # Search provider:
   #   0: No search engine
-  #   1: Algolia (https://www.algolia.com)
-  engine = 0
+  #   1: Built-in (Fuse)
+  #   2: Algolia (https://www.algolia.com)
+  engine = 1
 
   # Configuration of Algolia search engine.
   # Paste the values from your Algolia dashboard.

+ 1 - 1
exampleSite/content/home/search.md

@@ -1,7 +1,7 @@
 +++
 # Search widget.
 widget = "search"
-active = false
+active = true
 date = 2018-07-23T00:00:00
 
 title = "Search"

+ 27 - 0
layouts/partials/css/academic.css

@@ -247,9 +247,32 @@ small,
  **************************************************/
 
 #search-box {
+  position: relative; /* Required for search icon positioning. */
   margin-bottom: 0.5rem;
 }
 
+#search-box::before {
+  content: "\f002";
+  font-family: FontAwesome;
+  font-size: 1rem;
+  opacity: 0.25;
+  line-height: 1rem;
+  position: absolute;
+  left: 0.7rem;
+  top: 0.6rem;
+  overflow-x: hidden;
+}
+
+#search-query {
+  border: 1px solid #dedede;
+  border-radius: 1rem;
+  padding: 1rem 1rem 1rem 2rem; /* Wider left padding for search icon to fit in. */
+  width: 250px;
+  line-height: 1rem;
+  height: 1rem;
+  font-size: 0.8rem;
+}
+
 .search-hit em {
   font-style: normal;
   background-color: #FFE0B2;
@@ -1620,6 +1643,10 @@ body.dark {
   background-color: rgb(68, 71, 90);
 }
 
+.dark #search-query {
+  background-color: rgb(68, 71, 90);
+}
+
 .dark .label-default {
   color: rgba(255, 255, 255, .68);
   background: rgba(255, 255, 255, .2);

+ 28 - 11
layouts/partials/footer.html

@@ -63,20 +63,37 @@
     <script>hljs.initHighlightingOnLoad();</script>
     {{ end }}
 
-    {{/* Algolia search engine. */}}
+    {{ if ne .Site.Params.search.engine 0 }}
+    {{/* Configure search engine. */}}
+    <script>
+      const search_index_filename = {{ "/search.json" | relURL }};
+      const i18n = {
+        'placeholder': {{ i18n "search_placeholder" }},
+        'no_results': {{ i18n "search_no_results" }}
+      };
+      const content_type = {
+        'post': {{ i18n "posts" }},
+        'project': {{ i18n "projects" }},
+        'publication' : {{ i18n "publications" }},
+        'talk' : {{ i18n "talks" }}
+        };
+    </script>
+    {{ end }}
+
+    {{/* Fuse search engine. */}}
     {{ if eq .Site.Params.search.engine 1 }}
+    {{ printf "<script src=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\"></script>" (printf $js.fuse.url $js.fuse.version) $js.fuse.sri | safeHTML }}
+    {{ printf "<script src=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\"></script>" (printf $js.mark.url $js.mark.version) $js.mark.sri | safeHTML }}
+    <script src="{{ "/js/search.js" | relURL }}"></script>
+    {{ end }}
+
+    {{/* Algolia search engine. */}}
+    {{ if eq .Site.Params.search.engine 2 }}
     {{ if ($scr.Get "use_cdn") }}
     {{ printf "<script src=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\"></script>" (printf $js.instantsearch.url $js.instantsearch.version) $js.instantsearch.sri | safeHTML }}
     {{ end }}
     <script>
       if ( $('#search-box').length ) {
-        const content_type = {
-          'post': {{ i18n "posts" }},
-          'project': {{ i18n "projects" }},
-          'publication' : {{ i18n "publications" }},
-          'talk' : {{ i18n "talks" }}
-        };
-
         function getTemplate(templateName) {
           return document.querySelector(`#${templateName}-template`).innerHTML;
         }
@@ -108,7 +125,7 @@
             container: '#search-box',
             autofocus: false,
             poweredBy: {{ .Site.Params.search.algolia.show_logo | default false }},
-            placeholder: {{ i18n "search_placeholder" }}
+            placeholder: i18n.placeholder
           })
         );
 
@@ -118,8 +135,8 @@
             container: '#search-hits',
             escapeHits: true,
             templates: {
-              empty: '<div class="search-no-results">' + {{ i18n "search_no_results" }} + '</div>',
-              item: getTemplate('search-hit')
+              empty: '<div class="search-no-results">' + i18n.no_results + '</div>',
+              item: getTemplate('search-hit-algolia')
             },
             cssClasses: {
               showmoreButton: 'btn btn-primary btn-outline'

+ 1 - 1
layouts/partials/header.html

@@ -71,7 +71,7 @@
     {{ printf "<link rel=\"stylesheet\" href=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\">" (printf $css.leaflet.url $css.leaflet.version) $css.leaflet.sri | safeHTML }}
     {{ end }}
 
-    {{ if eq .Site.Params.search.engine 1 }}
+    {{ if eq .Site.Params.search.engine 2 }}
       {{ printf "<link rel=\"stylesheet\" href=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\">" (printf $css.instantsearch.url $css.instantsearch.version) $css.instantsearch.sri | safeHTML }}
       {{ printf "<link rel=\"stylesheet\" href=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\">" (printf $css.instantsearchTheme.url $css.instantsearchTheme.version) $css.instantsearchTheme.sri | safeHTML }}
     {{ end }}

+ 21 - 1
layouts/partials/widgets/search.html

@@ -10,7 +10,11 @@
     {{ with $page.Content }}<p>{{ . | markdownify }}</p>{{ end }}
 
     <div id="search-box">
+      {{ if eq $.Site.Params.search.engine 1 }}
+      <input name="q" id="search-query" placeholder="{{ i18n "search_placeholder" }}" autocapitalize="off" autocomplete="off" autocorrect="off" role="textbox" spellcheck="false" type="search">
+      {{ else }}
       <!-- Search box will appear here -->
+      {{ end }}
     </div>
 
     <div id="search-hits">
@@ -20,7 +24,22 @@
   </div>
 </div>
 
-<script type="text/html" id="search-hit-template">
+{{ if eq $.Site.Params.search.engine 1 }}
+{{/* Fuse search result template. */}}
+<script id="search-hit-fuse-template" type="text/x-template">
+  <div class="search-hit" id="summary-{{"{{key}}"}}">
+    <div class="search-hit-content">
+      <div class="search-hit-name">
+        {{ printf "<a href=\"%s\">%s</a>" "{{relpermalink}}" "{{title}}" | safeHTML }}
+        <div class="article-metadata search-hit-type">{{"{{type}}"}}</div>
+        <p class="search-hit-description">{{"{{snippet}}"}}</p>
+      </div>
+    </div>
+  </div>
+</script>
+{{ else }}
+{{/* Algolia search result template. */}}
+<script id="search-hit-algolia-template" type="text/html">
   <div class="search-hit">
     <div class="search-hit-content">
       <div class="search-hit-name">
@@ -31,3 +50,4 @@
     </div>
   </div>
 </script>
+{{ end }}

+ 165 - 0
static/js/search.js

@@ -0,0 +1,165 @@
+/*************************************************
+ *  Academic: the website framework for Hugo.
+ *  https://github.com/gcushen/hugo-academic
+ **************************************************/
+
+/* ---------------------------------------------------------------------------
+* Configuration.
+* --------------------------------------------------------------------------- */
+
+// Configure Fuse.
+let fuseOptions = {
+  shouldSort: true,
+  includeMatches: true,
+  tokenize: true,
+  threshold: 0.0,
+  location: 0,
+  distance: 100,
+  maxPatternLength: 32,
+  minMatchCharLength: 2,
+  keys: [
+    {name:'title', weight:0.8},
+    {name:'summary', weight:0.6},
+    {name:'content', weight:0.5},
+    {name:'tags', weight:0.3}
+  ]
+};
+
+// Configure summary.
+let summaryLength = 60;
+
+/* ---------------------------------------------------------------------------
+* Functions.
+* --------------------------------------------------------------------------- */
+
+// Get query from URI.
+function getSearchQuery(name) {
+  return decodeURIComponent((location.search.split(name + '=')[1] || '').split('&')[0]).replace(/\+/g, ' ');
+}
+
+// Set query in URI without reloading the page.
+function updateURL(url) {
+  if (history.pushState) {
+    window.history.pushState({path:url}, '', url);
+  }
+}
+
+// Pre-process new search query.
+function initSearch(force, fuse) {
+  let query = $("#search-query").val();
+
+  // If query deleted, clear results.
+  if ( query.length < 1)
+    $('#search-hits').empty();
+
+  // Check for timer event (enter key not pressed) and query less than minimum length required.
+  if (!force && query.length < fuseOptions.minMatchCharLength)
+    return;
+
+  // Do search.
+  $('#search-hits').empty();
+  searchAcademic(query, fuse);
+  let newURL = window.location.protocol + "//" + window.location.host + window.location.pathname + '?q=' + encodeURIComponent(query) + window.location.hash;
+  updateURL(newURL);
+}
+
+// Perform search.
+function searchAcademic(query, fuse) {
+  let results = fuse.search(query);
+  // console.log({"results": results});
+  if (results.length > 0) {
+    parseResults(query, results);
+  } else {
+    $('#search-hits').append('<div class="search-no-results">' + i18n.no_results + '</div>');
+  }
+}
+
+// Parse search results.
+function parseResults(query, results) {
+  $.each( results, function(key, value) {
+    let content = value.item.content;
+    let snippet = "";
+    let snippetHighlights = [];
+
+    if ( fuseOptions.tokenize ) {
+      snippetHighlights.push(query);
+    } else {
+      $.each( value.matches, function(matchKey, matchValue) {
+        if (matchValue.key == "content") {
+          let start = (matchValue.indices[0][0]-summaryLength>0) ? matchValue.indices[0][0]-summaryLength : 0;
+          let end = (matchValue.indices[0][1]+summaryLength<content.length) ? matchValue.indices[0][1]+summaryLength : content.length;
+          snippet += content.substring(start, end);
+          snippetHighlights.push(matchValue.value.substring(matchValue.indices[0][0], matchValue.indices[0][1]-matchValue.indices[0][0]+1));
+        }
+      });
+    }
+
+    if (snippet.length < 1) {
+      snippet += content.substring(0, summaryLength*2);
+    }
+
+    // Load template.
+    var template = $('#search-hit-fuse-template').html();
+
+    // Localize content types.
+    let content_key = value.item.type;
+    if (content_key in content_type) {
+      content_key = content_type[content_key];
+    }
+
+    // Parse template.
+    let templateData = {
+      key: key,
+      title: value.item.title,
+      type: content_key,
+      relpermalink: value.item.relpermalink,
+      snippet: snippet
+    };
+    let output = render(template, templateData);
+    $('#search-hits').append(output);
+
+    // Highlight search terms in result.
+    $.each( snippetHighlights, function(hlKey, hlValue){
+      $("#summary-"+key).mark(hlValue);
+    });
+
+  });
+}
+
+function render(template, data) {
+  // Replace placeholders with their values.
+  let key, find, re;
+  for (key in data) {
+    find = '\\{\\{\\s*' + key + '\\s*\\}\\}';  // Expect placeholder in the form `{{x}}`.
+    re = new RegExp(find, 'g');
+    template = template.replace(re, data[key]);
+  }
+  return template;
+}
+
+/* ---------------------------------------------------------------------------
+* Initialize.
+* --------------------------------------------------------------------------- */
+
+// Wait for Fuse to initialize.
+$.getJSON( search_index_filename, function( search_index ) {
+  let fuse = new Fuse(search_index, fuseOptions);
+
+  // On page load, check for search query in URL.
+  if (query = getSearchQuery('q')) {
+    $("#search-query").val(query);
+    initSearch(true, fuse);
+  }
+
+  // On search box key up, process query.
+  $('#search-query').keyup(function (e) {
+    clearTimeout($.data(this, 'searchTimer')); // Ensure only one timer runs!
+    if (e.keyCode == 13) {
+      initSearch(true, fuse);
+    } else {
+      $(this).data('searchTimer', setTimeout(function () {
+        initSearch(false, fuse);
+      }, 250));
+    }
+  });
+});