implement search capability from https://github.com/onweru/compose

This commit is contained in:
John Bowdre 2022-01-29 16:34:29 -06:00
parent 0c1d9100f5
commit 9ed2c8d9c9
12 changed files with 444 additions and 3 deletions

View file

@ -11,3 +11,75 @@ Array.from(myLabels).forEach(label => {
};
});
});
// Search helpers from https://github.com/onweru/compose
const rootURL = window.location.protocol + "//" + window.location.host;
const searchFieldClass = '.search_field';
const searchClass = '.search';
const quickLinks = '{{ T "quick_links" }}';
const searchResultsLabel = '{{ T "search_results_label" }}';
const shortSearchQuery = '{{ T "short_search_query" }}'
const typeToSearch = '{{ T "type_to_search" }}';
const noMatchesFound = '{{ T "no_matches" }}';
function createEl(element = 'div') {
return document.createElement(element);
}
function emptyEl(el) {
while(el.firstChild)
el.removeChild(el.firstChild);
}
function wrapText(text, context, wrapper = 'mark') {
let open = `<${wrapper}>`;
let close = `</${wrapper}>`;
let escapedOpen = `%3C${wrapper}%3E`;
let escapedClose = `%3C/${wrapper}%3E`;
function wrap(context) {
let c = context.innerHTML;
let pattern = new RegExp(text, "gi");
let matches = text.length ? c.match(pattern) : null;
if(matches) {
matches.forEach(function(matchStr){
c = c.replaceAll(matchStr, `${open}${matchStr}${close}`);
context.innerHTML = c;
});
const images = elems('img', context);
if(images) {
images.forEach(image => {
image.src = image.src.replaceAll(open, '').replaceAll(close, '').replaceAll(escapedOpen, '').replaceAll(escapedClose, '');
});
}
}
}
const contents = ["h1", "h2", "h3", "h4", "h5", "h6", "p", "code", "td"];
contents.forEach(function(c){
const cs = elems(c, context);
if(cs.length) {
cs.forEach(function(cx, index){
if(cx.children.length >= 1) {
Array.from(cx.children).forEach(function(child){
wrap(child);
})
wrap(cx);
} else {
wrap(cx);
}
// sanitize urls and ids
});
}
});
const hyperLinks = elems('a');
if(hyperLinks) {
hyperLinks.forEach(function(link){
link.href = link.href.replaceAll(encodeURI(open), "").replaceAll(encodeURI(close), "");
});
}
}

View file

@ -7,6 +7,14 @@ html, body
&_brand
img
max-height: 2rem
// search box
&-item
display: grid
align-items: center
.search
@media screen and (min-width: 992px)
margin-right: 1.5rem
// Magic for collapsing content, borrowed from https://www.digitalocean.com/community/tutorials/css-collapsible
.lbl-toggle
@ -65,3 +73,58 @@ code
word-break: normal
&.noClass
line-break: normal
// search from https://github.com/onweru/compose
.search
flex: 1
display: flex
justify-content: flex-end
position: relative
&_field
padding: 0.5rem 1.5rem 0.5rem 2.5rem
border-radius: 1.5rem
width: 13.5rem
outline: none
border: none
background: transparent
color: var(--text)
box-shadow: 0 1rem 4rem rgba(0,0,0,0.17)
font-size: 1rem
&_label
width: 1rem
height: 1rem
position: absolute
left: 0.33rem
top: 0.25rem
opacity: 0.33
svg
width: 100%
height: 100%
fill: var(--text)
&_result
padding: 0.5rem 1rem
&:not(.passive):hover
background-color: var(--theme)
color: var(--light)
&.passive
display: grid
&s
width: 13.5rem
background-color: var(--bg)
border-radius: 0 0 0.25rem 0.25rem
box-shadow: 0 1rem 4rem rgba(0,0,0,0.17)
position: absolute
top: 125%
display: grid
overflow: hidden
z-index: 5
&:empty
display: none
&_title
padding: 0.5rem 1rem 0.5rem 1rem
background: var(--theme)
color: var(--light)
font-size: 0.9rem
opacity: 0.87
text-transform: uppercase

View file

@ -10,3 +10,6 @@ DefaultContentLanguage = "en"
[permalinks]
posts = ":filename"
[outputs]
home = ["HTML", "RSS","JSON"]

5
content/search.md Normal file
View file

@ -0,0 +1,5 @@
+++
title = "Search"
searchPage = true
type = "search"
+++

View file

@ -15,3 +15,21 @@ other = "and"
[view_source]
other = "View source"
[no_matches]
other = "No matches found"
[quick_links]
other = "Quick links"
[search_field_placeholder]
other = "Search"
[search_results_label]
other = "Search Results"
[short_search_query]
other = "Query is too short"
[type_to_search]
other = "Type to search"

View file

@ -0,0 +1,7 @@
{{- $.Scratch.Add "index" slice -}}
{{- range .Site.Pages -}}
{{- if ne .Type "search" -}}
{{- $.Scratch.Add "index" (dict "title" .Title "body" .Plain "link" .Permalink) -}}
{{- end -}}
{{- end -}}
{{- jsonify (uniq ($.Scratch.Get "index")) -}}

View file

@ -61,4 +61,7 @@
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="edit">
<path d="M0 0h24v24H0V0z" fill="none"/><path d="m14.06 9.02.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83a.996.996 0 0 0 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z"/>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 511.999 511.999" id="search">
<path d="M508.874 478.708L360.142 329.976c28.21-34.827 45.191-79.103 45.191-127.309C405.333 90.917 314.416 0 202.666 0S0 90.917 0 202.667s90.917 202.667 202.667 202.667c48.206 0 92.482-16.982 127.309-45.191l148.732 148.732c4.167 4.165 10.919 4.165 15.086 0l15.081-15.082c4.165-4.166 4.165-10.92-.001-15.085zM202.667 362.667c-88.229 0-160-71.771-160-160s71.771-160 160-160 160 71.771 160 160-71.771 160-160 160z"></path>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 10 KiB

39
layouts/partials/nav.html Normal file
View file

@ -0,0 +1,39 @@
{{- $menu := .menu }}
{{ $menuData := .context.Site.Data.menu }}
{{- $link := .context.Permalink }}
{{- $url := "" }}
{{- $name := "" }}
{{- $forwardSlash := "/" }}
{{- $children := false }}
{{- range $menu }}
{{- if eq $menu $menuData }}
{{- $children = .submenu }}
{{- $name = .name }}
{{- $url = absURL .link }}
{{- else }}
{{- $children = .Children }}
{{- $name = .Name }}
{{- $url = absLangURL .URL }}
{{- end }}
<div class="nav_parent{{ if (and (not .Children) (eq (trim $url $forwardSlash) (trim $link $forwardSlash))) }} nav_active{{ end}}">
<a href="{{ $url }}" class="nav_item" title="{{ $name }}">{{ $name }} {{ with $children }}<img src='{{ absURL "icons/caret-icon.svg" }}' alt="icon" class="nav_icon">{{ end }}</a>
{{- with $children }}
<div class="nav_sub">
<span class="nav_child"></span>
{{- range . }}
{{- if eq $menu $menuData }}
{{- $name = .name }}
{{- $url = absURL .link }}
{{- else }}
{{- $name = .Name }}
{{- $url = absLangURL .URL }}
{{- end }}
<a href="{{ $url }}" class="nav_child nav_item" title="{{ $name }}">{{ $name }}</a>
{{- end }}
</div>
{{- end }}
</div>
{{- end }}
<li class="nav-item">
{{- partial "search" . }}
</li>

View file

@ -0,0 +1,12 @@
<!-- search from https://github.com/onweru/compose -->
<div class="search">
<label for="find" class="search_label">
<svg class="icon icon_search">
<use xlink:href="#search"></use>
</svg>
</label>
<input type="search" class="search_field" placeholder='{{ T "search_field_placeholder" }}' id="find" autocomplete="off">
<div class="search_results results"></div>
</div>
<script src="{{ "/js/fuse.js" | relURL }}"></script>
<script src="{{ "/js/search.js" | relURL }}"></script>

View file

@ -0,0 +1,5 @@
{{- define "main" }}
<main id="results">
<div class="results" id="searchpage"></div>
</main>
{{- end }}

9
static/js/fuse.js Normal file

File diff suppressed because one or more lines are too long

205
static/js/search.js Normal file
View file

@ -0,0 +1,205 @@
// search from https://github.com/onweru/compose
function initializeSearch(index) {
const searchKeys = ['title', 'link', 'body', 'id'];
const searchPageElement = elem('#searchpage');
const searchOptions = {
ignoreLocation: true,
findAllMatches: true,
includeScore: true,
shouldSort: true,
keys: searchKeys,
threshold: 0.0
};
index = new Fuse(index, searchOptions);
function minQueryLen(query) {
query = query.trim();
const queryIsFloat = parseFloat(query);
const minimumQueryLength = queryIsFloat ? 1 : 2;
return minimumQueryLength;
}
function searchResults(results=[], query="", passive = false) {
let resultsFragment = new DocumentFragment();
let showResults = elem('.search_results');
if(passive) {
showResults = searchPageElement;
}
emptyEl(showResults);
const queryLen = query.length;
const requiredQueryLen = minQueryLen(query);
if(results.length && queryLen >= requiredQueryLen) {
let resultsTitle = createEl('h3');
resultsTitle.className = 'search_title';
resultsTitle.innerText = quickLinks;
if(passive) {
resultsTitle.innerText = searchResultsLabel;
}
resultsFragment.appendChild(resultsTitle);
if(!searchPageElement) {
results = results.slice(0,8);
} else {
results = results.slice(0,12);
}
results.forEach(function(result){
let item = createEl('a');
item.href = `${result.link}?query=${query}`;
item.className = 'search_result';
item.style.order = result.score;
if(passive) {
pushClass(item, 'passive');
let itemTitle = createEl('h3');
itemTitle.textContent = result.title;
item.appendChild(itemTitle);
let itemDescription = createEl('p');
// position of first search term instance
let queryInstance = result.body.indexOf(query);
itemDescription.textContent = `... ${result.body.substring(queryInstance, queryInstance + 200)} ...`;
item.appendChild(itemDescription);
} else {
item.textContent = result.title;
}
resultsFragment.appendChild(item);
});
}
if(queryLen >= requiredQueryLen) {
if (!results.length) {
showResults.innerHTML = `<span class="search_result">${noMatchesFound}</span>`;
}
} else {
showResults.innerHTML = `<label for="find" class="search_result">${ queryLen > 1 ? shortSearchQuery : typeToSearch }</label>`
}
showResults.appendChild(resultsFragment);
}
function search(searchTerm, passive = false) {
if(searchTerm.length) {
let rawResults = index.search(searchTerm);
rawResults = rawResults.map(function(result){
const score = result.score;
const resultItem = result.item;
resultItem.score = (parseFloat(score) * 50).toFixed(0);
return resultItem;
});
passive ? searchResults(rawResults, searchTerm, true) : searchResults(rawResults, searchTerm);
} else {
passive ? searchResults([], "", true) : searchResults();
}
}
function liveSearch() {
const searchField = elem(searchFieldClass);
if (searchField) {
searchField.addEventListener('input', function() {
const searchTerm = searchField.value.trim().toLowerCase();
search(searchTerm);
});
if(!searchPageElement) {
searchField.addEventListener('search', function(){
const searchTerm = searchField.value.trim().toLowerCase();
if(searchTerm.length) {
window.location.href = new URL(`search/?query=${searchTerm}`, rootURL).href;
}
});
}
}
}
function findQuery(query = 'query') {
const urlParams = new URLSearchParams(window.location.search);
if(urlParams.has(query)){
let c = urlParams.get(query);
return c;
}
return "";
}
function passiveSearch() {
if(searchPageElement) {
const searchTerm = findQuery();
search(searchTerm, true);
// search actively after search page has loaded
const searchField = elem(searchFieldClass);
if(searchField) {
searchField.addEventListener('input', function() {
const searchTerm = searchField.value.trim().toLowerCase();
search(searchTerm, true);
wrapText(searchTerm, main);
});
}
}
}
function hasSearchResults() {
const searchResults = elem('.results');
if(searchResults) {
const body = searchResults.innerHTML.length;
return [searchResults, body];
}
return false
}
function clearSearchResults() {
let searchResults = hasSearchResults();
if(searchResults) {
searchResults = searchResults[0];
searchResults.innerHTML = "";
// clear search field
const searchField = elem(searchFieldClass);
searchField.value = "";
}
}
function onEscape(fn){
window.addEventListener('keydown', function(event){
if(event.code === "Escape") {
fn();
}
});
}
let main = elem('main');
if(!main) {
main = elem('.main');
}
searchPageElement ? false : liveSearch();
passiveSearch();
wrapText(findQuery(), main);
onEscape(clearSearchResults);
window.addEventListener('click', function(event){
const target = event.target;
const isSearch = target.closest(searchClass) || target.matches(searchClass);
if(!isSearch && !searchPageElement) {
clearSearchResults();
}
});
}
window.addEventListener('load', function() {
fetch(new URL("index.json", rootURL).href)
.then(response => response.json())
.then(function(data) {
data = data.length ? data : [];
initializeSearch(data);
})
.catch((error) => console.error(error));
});