-
Notifications
You must be signed in to change notification settings - Fork 43
/
search.js
540 lines (485 loc) · 19 KB
/
search.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
const body = document.body;
const searchWrapper = document.querySelector(".search-wrapper");
const searchModal = document.querySelector(".search-modal");
const searchFooter = document.querySelector(".search-wrapper-footer");
const searchResult = document.querySelectorAll("[data-search-result]");
const searchResultItemTemplate = document.getElementById("search-result-item-template");
const hasSearchWrapper = searchWrapper != null;
const hasSearchModal = searchModal != null;
const searchInput = document.querySelectorAll("[data-search-input]");
const emptySearchResult = document.querySelectorAll(".search-result-empty");
const openSearchModal = document.querySelectorAll('[data-target="search-modal"]');
const closeSearchModal = document.querySelectorAll('[data-target="close-search-modal"]');
const searchIcon = document.querySelector(".search-input-body label svg[data-type='search']");
const searchIconReset = document.querySelector(".search-input-body label svg[data-type='reset']");
const searchResultInfo = document.querySelector(".search-result-info");
let searchModalVisible = hasSearchModal && searchModal.classList.contains("show") ? true : false;
let jsonData = [];
const loadJsonData = async () => {
try {
const res = await fetch(indexURL);
return (jsonData = await res.json());
} catch (err) {
console.error(err);
}
};
if (hasSearchWrapper) {
// disable enter key on searchInput
searchInput.forEach((el) => {
el.addEventListener("keypress", (e) => {
if (e.keyCode == 13) {
e.preventDefault();
}
});
});
// Capitalize First Letter
const capitalizeFirstLetter = (string) => {
return string
.replace(/^[\s_]+|[\s_]+$/g, "")
.replace(/[_\s]+/g, " ")
.replace(/^[a-z]/, function (m) {
return m.toUpperCase();
});
};
// String to URL
const urlize = (string) => {
let lowercaseText = string.trim().replace(/[\s_]+/g, '-').toLowerCase();
return encodeURIComponent(lowercaseText);
}
// options
const image = searchWrapper.getAttribute("data-image");
const description = searchWrapper.getAttribute("data-description");
const tags = searchWrapper.getAttribute("data-tags");
const categories = searchWrapper.getAttribute("data-categories");
let searchString = "";
// get search string from url
const urlParams = new URLSearchParams(window.location.search);
const urlSearchString = urlParams.get("s");
if (urlSearchString !== null) {
searchString = urlSearchString.replace(/\+/g, " ");
searchInput.forEach((el) => {
el.value = searchString;
});
searchIcon && (searchIcon.style.display = "none");
searchIconReset && (searchIconReset.style.display = "initial");
}
searchInput.forEach((el) => {
el.addEventListener("input", (e) => {
searchString = e.target.value.toLowerCase();
window.history.replaceState(
{},
"",
`${window.location.origin}${window.location.pathname
}?s=${searchString.replace(/ /g, "+")}`
);
doSearch(searchString);
});
});
// dom content loaded
document.addEventListener("DOMContentLoaded", async () => {
await loadJsonData();
doSearch(searchString);
});
// doSearch
const doSearch = async (searchString) => {
if (searchString !== "") {
searchIcon && (searchIcon.style.display = "none");
searchIconReset && (searchIconReset.style.display = "initial");
emptySearchResult.forEach((el) => {
el.innerHTML = `<div class="search-not-found">
<svg width="42" height="42" viewBox="0 0 47 47" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.10368 33.9625C9.90104 36.2184 13.2988 37.6547 16.9158 38.0692C21.6958 38.617 26.5063 37.3401 30.3853 34.4939C30.4731 34.6109 30.5668 34.7221 30.6721 34.8304L41.9815 46.1397C42.5323 46.6909 43.2795 47.0007 44.0587 47.001C44.838 47.0013 45.5854 46.692 46.1366 46.1412C46.6878 45.5904 46.9976 44.8432 46.9979 44.064C46.9981 43.2847 46.6888 42.5373 46.138 41.9861L34.8287 30.6767C34.7236 30.5704 34.6107 30.4752 34.4909 30.3859C37.3352 26.5046 38.6092 21.6924 38.0579 16.912C37.6355 13.2498 36.1657 9.81322 33.8586 6.9977L31.7805 9.09214C34.0157 11.9274 35.2487 15.4472 35.2487 19.0942C35.2487 21.2158 34.8308 23.3167 34.0189 25.2769C33.207 27.2371 32.0169 29.0181 30.5167 30.5184C29.0164 32.0186 27.2354 33.2087 25.2752 34.0206C23.315 34.8325 21.2141 35.2504 19.0925 35.2504C16.9708 35.2504 14.8699 34.8325 12.9098 34.0206C11.5762 33.4682 10.3256 32.7409 9.18992 31.8599L7.10368 33.9625ZM28.9344 6.28152C26.1272 4.12516 22.671 2.93792 19.0925 2.93792C14.8076 2.93792 10.6982 4.64009 7.66829 7.66997C4.6384 10.6999 2.93623 14.8093 2.93623 19.0942C2.93623 21.2158 3.35413 23.3167 4.16605 25.2769C4.72475 26.6257 5.4625 27.8897 6.35716 29.0358L4.2702 31.1391C1.35261 27.548 -0.165546 23.0135 0.00974294 18.3781C0.19158 13.5695 2.18233 9.00695 5.58371 5.60313C8.98509 2.19932 13.5463 0.205307 18.3547 0.0200301C22.9447 -0.156832 27.4369 1.32691 31.0132 4.18636L28.9344 6.28152Z" fill="currentColor"/><path d="M3.13672 39.1367L38.3537 3.64355" stroke="black" stroke-width="3" stroke-linecap="round"/></svg><p>${no_results_for} "<b>${searchString}</b>"</p></div>`;
});
} else {
searchIcon && (searchIcon.style.display = "initial");
searchIconReset && (searchIconReset.style.display = "none");
emptySearchResult.forEach((el) => {
el.innerHTML = empty_search_results_placeholder;
});
}
let filteredJSON = includeSectionsInSearch.map((section) => {
const data = jsonData.filter(
(item) => urlize(item.section) === urlize(section)
);
const sectionName = section.replace(/[-_]/g, " ");
return {
section: capitalizeFirstLetter(sectionName),
data,
};
});
let searchItem = filteredJSON.filter((item) => {
if (searchString === "") {
return false;
}
return item.data.some((el) => {
const regex = new RegExp(searchString, "gi");
return (
el.title.toLowerCase().match(regex) ||
el.description?.toLowerCase().match(regex) ||
el.searchKeyword.toLowerCase().match(regex) ||
el.content.toLowerCase().match(regex) ||
el.tags?.toLowerCase().match(regex) ||
el.categories?.toLowerCase().match(regex)
);
});
});
displayResult(searchItem, searchString);
// Navigate with arrow keys
if (searchModal && searchString != "") {
let resItems;
resItems = searchResult[0].querySelectorAll(".search-result-item");
let selectedIndex = -1;
const selectItem = (index) => {
if (index >= 0 && index < resItems.length) {
for (let i = 0; i < resItems.length; i++) {
resItems[i].classList.toggle("search-item-selected", i === index);
}
selectedIndex = index;
resItems[index].scrollIntoView({
behavior: "auto",
block: "nearest",
});
}
};
const handleKeyDown = (event) => {
if (searchItem.length !== 0) {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
if (event.key === "ArrowUp") {
selectedIndex =
selectedIndex > 0 ? selectedIndex - 1 : resItems.length - 1;
} else if (event.key === "ArrowDown") {
selectedIndex =
selectedIndex < resItems.length - 1 ? selectedIndex + 1 : 0;
}
selectItem(selectedIndex !== -1 ? selectedIndex : -1);
} else if (event.key === "Enter") {
event.preventDefault();
if (selectedIndex !== -1) {
let selectedLink = resItems[selectedIndex]
.getElementsByClassName("search-title")[0]
.getAttribute("href");
window.location.href = selectedLink;
}
}
}
};
searchInput.forEach((el) => {
el.addEventListener("keydown", handleKeyDown);
});
selectItem(-1);
}
};
const displayResult = (searchItems, searchString) => {
const generateSearchResultHTML = (item) => {
const contentValue = item.data
.filter((d) => d.content.toLowerCase().includes(searchString))
.map((innerItem) => {
const position = innerItem.content
.toLowerCase()
.indexOf(searchString.toLowerCase());
let matches = innerItem.content.substring(
position,
searchString.length + position
);
let matchesAfter = innerItem.content.substring(
searchString.length + position,
searchString.length + position + 80
);
const highlighted = innerItem.content.replace(
innerItem.content,
"<mark>" + matches + "</mark>" + matchesAfter
);
return highlighted;
});
const highlightResult = (content) => {
const regex = new RegExp(searchString, "gi");
return content.replace(regex, (match) => `<u>${match}</u>`);
};
const highlightResultContent = (content) => {
const regex = new RegExp(searchString, "gi");
const matchIndex = content.search(regex);
if (matchIndex >= 0) {
const matchedContent = content.slice(matchIndex);
const lastWord = content.slice(0, matchIndex).split(" ").pop();
return matchedContent.replace(
regex,
(match) => lastWord + `<mark>${match}</mark>`
);
}
return content;
};
const filteredItems = item.data.filter(
(d) =>
d.title.toLowerCase().includes(searchString) ||
(description === "true"
? d.description?.toLowerCase().includes(searchString)
: "") ||
d.searchKeyword.toLowerCase().includes(searchString) ||
(tags === "true"
? d.tags?.toLowerCase().includes(searchString)
: "") ||
(categories === "true"
? d.categories?.toLowerCase().includes(searchString)
: "") ||
d.content.toLowerCase().includes(searchString)
);
// pull template from hugo templarte definition
let templateDefinition =
searchResultItemTemplate != null
? searchResultItemTemplate.innerHTML
: `
<div class="search-result-item">
<div class="search-image">#{image}</div>
<div class="search-content-block">
<a href="#{slug}" class="search-title">#{title}</a>
<p class="search-description">#{description}</p>
<p class="search-content">#{content}</p>
<div class="search-info">
<div>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" style="margin-top:-2px">
<path d="M11 0H3a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2 2 2 0 0 0 2-2V4a2 2 0 0 0-2-2 2 2 0 0 0-2-2zm2 3a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1V3zM2 2a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V2z"/>
</svg>
#{categories}
</div>
<div>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M3 2v4.586l7 7L14.586 9l-7-7H3zM2 2a1 1 0 0 1 1-1h4.586a1 1 0 0 1 .707.293l7 7a1 1 0 0 1 0 1.414l-4.586 4.586a1 1 0 0 1-1.414 0l-7-7A1 1 0 0 1 2 6.586V2z"/>
<path d="M5.5 5a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0 1a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM1 7.086a1 1 0 0 0 .293.707L8.75 15.25l-.043.043a1 1 0 0 1-1.414 0l-7-7A1 1 0 0 1 0 7.586V3a1 1 0 0 1 1-1v5.086z"/>
</svg>
#{tags}
</div>
</div>
</div>
</div>`;
const renderedItems = filteredItems
.map((innerItem) => {
let output = renderResult(templateDefinition, {
slug: innerItem.slug,
date: innerItem.date,
description:
description == "true"
? highlightResult(innerItem.description)
: "",
title: highlightResult(innerItem.title),
image: image == "true" ? innerItem.image : "",
tags: tags == "true" ? highlightResult(innerItem.tags) : "nomatch",
categories:
categories == "true"
? highlightResult(innerItem.categories)
: "nomatch",
content: highlightResultContent(innerItem.content),
});
return output;
})
.join("");
return `
<div class="search-result-group">
<p class="search-result-group-title">${item.section}</p>
${renderedItems}
</div>`;
};
const filteredItemsLength = searchItems.reduce((totalLength, item) => {
const filteredItems = item.data.filter(
(d) =>
d.title.toLowerCase().includes(searchString) ||
(description === "true"
? d.description?.toLowerCase().includes(searchString)
: "") ||
d.searchKeyword.toLowerCase().includes(searchString) ||
(tags === "true"
? d.tags?.toLowerCase().includes(searchString)
: "") ||
(categories === "true"
? d.categories?.toLowerCase().includes(searchString)
: "") ||
d.content.toLowerCase().includes(searchString)
);
return totalLength + filteredItems.length;
}, 0);
// count time start
const startTime = performance.now();
// Render Result into HTML
const htmlString = searchItems.map(generateSearchResultHTML).join("");
searchResult.forEach((el) => {
el.innerHTML = htmlString;
});
// count time end
const endTime = performance.now();
// count total-result and time
let totalResults = `<em>${filteredItemsLength}</em> results`;
let totalTime = ((endTime - startTime) / 1000).toFixed(3);
totalTime = `- in <em>${totalTime}</em> seconds`;
searchResultInfo &&
(searchResultInfo.innerHTML =
filteredItemsLength > 0 ? `${totalResults} ${totalTime}` : "");
// hide search-result-group-title if un-available result
const groupTitle = document.querySelectorAll(".search-result-group-title");
groupTitle.forEach((el) => {
// hide search-result-group-title if there is no result
if (el.nextElementSibling === null) {
el.style.display = "none";
}
// hide emptySearchResult if there is no result
if (el.nextElementSibling != null) {
emptySearchResult.forEach((el) => {
el.style.display = "";
});
} else {
emptySearchResult.forEach((el) => {
el.style.display = "block";
});
}
});
// hide tag/category if un-available result
const searchInfo = document.querySelectorAll(".search-info > div");
if (searchInfo.length > 0) {
// hide tag/category if there is no result
searchInfo.forEach((el) => {
if (el.innerText.includes("nomatch") || el.innerText == "") {
el.classList.add("hidden");
}
});
}
};
loadJsonData();
}
// Render Result Template
const renderResult = (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]]) {
// if valid key, remove conditionals, leave contents.
copy = copy.replace(conditionalMatches[0], conditionalMatches[2]);
} else {
// if 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;
};
// ========================================================================================
// Reset Serach
const resetSearch = () => {
searchIcon && (searchIcon.style.display = "initial");
searchIconReset && (searchIconReset.style.display = "none");
searchInput.forEach((el) => {
el.value = "";
});
searchResult.forEach((el) => {
el.innerHTML = "";
});
emptySearchResult.forEach((el) => {
el.style.display = "";
el.innerHTML = empty_search_results_placeholder;
});
searchResultInfo.innerHTML = "";
// clear search query string from URL
if (window.location.search.includes("?s=")) {
window.history.pushState(
"",
document.title,
window.location.pathname + window.location.hash
);
}
};
// Body Scroll
const enableBodyScroll = () => {
setTimeout(() => {
body.style.overflowY = "";
body.style.paddingRight = "";
}, 200);
};
const disableBodyScroll = () => {
const documentWidth = document.documentElement.clientWidth;
const scrollbarWidth = Math.abs(window.innerWidth - documentWidth);
body.style.overflowY = "hidden";
body.style.paddingRight = scrollbarWidth + "px";
};
// Show/Hide Search Modal
const showModal = () => {
searchWrapper.classList.add("show");
window.setTimeout(
() => document.querySelector("[data-search-input]").focus(),
100
);
if (hasSearchModal) {
disableBodyScroll();
searchModalVisible = true;
}
};
const closeModal = () => {
searchWrapper.classList.remove("show");
resetSearch();
if (hasSearchModal) {
enableBodyScroll();
searchModalVisible = false;
}
};
// Trigger Search Modal Show/Hide Events
if (hasSearchWrapper) {
// Show Search Modal on page load
if (searchModalVisible) {
showModal();
}
// Trigger Reset Search
searchIconReset &&
searchIconReset.addEventListener("click", () => {
resetSearch();
});
// Open Search Modal with click
openSearchModal.forEach((el) => {
el.addEventListener("click", function () {
showModal();
});
});
// Close Search Modal with click
closeSearchModal.forEach((el) => {
el.addEventListener("click", function () {
closeModal();
});
});
// Close modal on click outside modal-body
searchWrapper.addEventListener("click", function (e) {
if (e.target.classList.contains("search-wrapper")) {
closeModal();
}
});
// Close modal with ESC
const closeSearchModalWithESC = (e) => {
if (e.key === "Escape") {
if (searchModalVisible) {
e.preventDefault();
closeModal();
}
}
};
// Toggle modal on Ctrl + K / Cmd + K
const toggleSearchModalWithK = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
if (searchModalVisible) {
e.preventDefault();
closeModal();
} else {
e.preventDefault();
showModal();
}
}
};
document.addEventListener("keydown", (e) => {
toggleSearchModalWithK(e);
closeSearchModalWithESC(e);
});
}