379 lines
11 KiB
JavaScript
379 lines
11 KiB
JavaScript
summaryInclude = 60;
|
|
|
|
const fuseOptions = {
|
|
shouldSort: true,
|
|
includeMatches: true,
|
|
threshold: 0.0,
|
|
tokenize: true,
|
|
location: 0,
|
|
distance: 100,
|
|
maxPatternLength: 32,
|
|
minMatchCharLength: 1,
|
|
keys: [
|
|
{ name: "title", weight: 0.9 },
|
|
{ name: "contents", weight: 0.5 },
|
|
{ name: "tags", weight: 0.3 },
|
|
{ name: "categories", weight: 0.3 },
|
|
],
|
|
};
|
|
|
|
// Display error message in the search results container
|
|
function displayError(message) {
|
|
const searchResultsElement = document.getElementById("search-results");
|
|
if (searchResultsElement) {
|
|
const sanitizedMessage = DOMPurify.sanitize(message);
|
|
searchResultsElement.innerHTML = `<div class="alert alert-danger">${sanitizedMessage}</div>`;
|
|
} else {
|
|
console.error("Search results container not found");
|
|
}
|
|
}
|
|
|
|
// Safely get DOM element with error handling
|
|
function getElement(id) {
|
|
const element = document.getElementById(id);
|
|
if (!element) {
|
|
console.error(`Element with ID '${id}' not found`);
|
|
}
|
|
return element;
|
|
}
|
|
|
|
// Debounce function to prevent excessive search calls
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
// Function to update URL with search query
|
|
function updateURL(query) {
|
|
try {
|
|
// Create a URL object from the current URL
|
|
const url = new URL(window.location.href);
|
|
|
|
if (query && query.length >= 2) {
|
|
// Set or update the 's' parameter
|
|
url.searchParams.set("s", query);
|
|
} else {
|
|
// Remove the 's' parameter if query is empty or too short
|
|
url.searchParams.delete("s");
|
|
}
|
|
|
|
// Update the URL without reloading the page
|
|
window.history.replaceState({}, "", url.toString());
|
|
} catch (error) {
|
|
console.error("Error updating URL:", error);
|
|
// Continue without URL update - non-critical error
|
|
}
|
|
}
|
|
|
|
// Safe parameter extraction with error handling
|
|
function param(name) {
|
|
try {
|
|
const paramValue = (location.search.split(`${name}=`)[1] || "").split("&")[0];
|
|
return paramValue ? decodeURIComponent(paramValue).replace(/\+/g, " ") : "";
|
|
} catch (error) {
|
|
console.error(`Error parsing URL parameter '${name}':`, error);
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// Get search query from URL parameter
|
|
const searchQuery = param("s");
|
|
try {
|
|
const searchInput = getElement("search-query");
|
|
const searchResults = getElement("search-results");
|
|
|
|
if (searchInput && searchQuery) {
|
|
searchInput.value = searchQuery;
|
|
executeSearch(searchQuery);
|
|
} else if (searchResults) {
|
|
searchResults.innerHTML =
|
|
"<div class='alert'>Please enter at least 2 characters to search</div>";
|
|
}
|
|
} catch (error) {
|
|
console.error("Error initializing search:", error);
|
|
displayError(
|
|
"There was a problem initializing the search. Please try again later.",
|
|
);
|
|
}
|
|
|
|
// Add event listener for real-time searching
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
try {
|
|
const searchInput = getElement("search-query");
|
|
if (!searchInput) {
|
|
throw new Error("Search input not found");
|
|
}
|
|
|
|
// Create debounced search function - 300ms is a good balance
|
|
const debouncedSearch = debounce((query) => {
|
|
// Update URL with current search query
|
|
updateURL(query);
|
|
|
|
const searchResults = getElement("search-results");
|
|
if (!searchResults) {
|
|
throw new Error("Search results container not found");
|
|
}
|
|
|
|
if (query.length >= 2) {
|
|
executeSearch(query);
|
|
} else if (query.length === 0 || query.length === 1) {
|
|
searchResults.innerHTML =
|
|
"<div class='alert'>Please enter at least 2 characters to search</div>";
|
|
}
|
|
}, 300);
|
|
|
|
// Set up input event for real-time searching
|
|
searchInput.addEventListener("input", function () {
|
|
const query = this.value.trim();
|
|
debouncedSearch(query);
|
|
});
|
|
|
|
// Handle form submission to prevent page reload
|
|
const searchForm = searchInput.closest("form");
|
|
if (searchForm) {
|
|
searchForm.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const query = searchInput.value.trim();
|
|
if (query.length >= 2) {
|
|
updateURL(query);
|
|
executeSearch(query);
|
|
}
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("Error setting up search event listeners:", error);
|
|
displayError(
|
|
"There was a problem setting up the search functionality. Please try reloading the page.",
|
|
);
|
|
}
|
|
});
|
|
|
|
function executeSearch(searchQuery) {
|
|
try {
|
|
if (!searchQuery || typeof searchQuery !== "string") {
|
|
throw new Error("Invalid search query");
|
|
}
|
|
|
|
const searchResults = getElement("search-results");
|
|
if (!searchResults) {
|
|
throw new Error("Search results container not found");
|
|
}
|
|
|
|
// Show loading indicator
|
|
searchResults.innerHTML =
|
|
'<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>';
|
|
|
|
fetch("/index.json")
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Network response was not ok: ${response.status} ${response.statusText}`,
|
|
);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then((data) => {
|
|
if (!Array.isArray(data)) {
|
|
throw new Error("Received invalid data format from server");
|
|
}
|
|
|
|
const pages = data;
|
|
const fuse = new Fuse(pages, fuseOptions);
|
|
const result = fuse.search(searchQuery);
|
|
|
|
// Clear previous results only when we have new results to show
|
|
searchResults.innerHTML = "";
|
|
|
|
if (result.length > 0) {
|
|
populateResults(result);
|
|
} else {
|
|
searchResults.insertAdjacentHTML(
|
|
"beforeend",
|
|
"<div class='alert'>No matches found</div>",
|
|
);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("Error executing search:", error);
|
|
displayError(`Failed to search: ${error.message}`);
|
|
});
|
|
} catch (error) {
|
|
console.error("Error in search execution:", error);
|
|
displayError("There was a problem with the search. Please try again later.");
|
|
}
|
|
}
|
|
|
|
function populateResults(result) {
|
|
try {
|
|
if (!Array.isArray(result)) {
|
|
throw new Error("Invalid search results");
|
|
}
|
|
|
|
const searchResults = getElement("search-results");
|
|
if (!searchResults) {
|
|
throw new Error("Search results container not found");
|
|
}
|
|
|
|
const templateElement = document.getElementById("search-result-template");
|
|
if (!templateElement) {
|
|
throw new Error("Search result template not found");
|
|
}
|
|
const templateDefinition = templateElement.innerHTML;
|
|
|
|
for (const [key, value] of result.entries()) {
|
|
if (!value || !value.item) {
|
|
console.warn("Skipping invalid search result item", value);
|
|
continue;
|
|
}
|
|
|
|
const contents = value.item.contents || "";
|
|
let snippet = "";
|
|
const snippetHighlights = [];
|
|
let start;
|
|
let end;
|
|
|
|
if (fuseOptions.tokenize) {
|
|
snippetHighlights.push(searchQuery);
|
|
} else {
|
|
if (value.matches) {
|
|
for (const mvalue of value.matches) {
|
|
if (!mvalue || typeof mvalue.key !== "string") {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
mvalue.key === "tags" ||
|
|
mvalue.key === "categories"
|
|
) {
|
|
snippetHighlights.push(mvalue.value);
|
|
} else if (
|
|
mvalue.key === "contents" &&
|
|
Array.isArray(mvalue.indices) &&
|
|
mvalue.indices.length > 0
|
|
) {
|
|
try {
|
|
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);
|
|
|
|
if (typeof mvalue.value === "string") {
|
|
const highlightValue =
|
|
mvalue.indices[0][1] - mvalue.indices[0][0] + 1;
|
|
if (
|
|
highlightValue > 0 &&
|
|
mvalue.indices[0][0] < mvalue.value.length
|
|
) {
|
|
snippetHighlights.push(
|
|
mvalue.value.substring(
|
|
mvalue.indices[0][0],
|
|
mvalue.indices[0][0] + highlightValue,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn("Error processing match indices", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (snippet.length < 1 && contents) {
|
|
snippet += contents.substring(0, summaryInclude * 2);
|
|
}
|
|
|
|
try {
|
|
// Insert the templated result
|
|
const output = render(templateDefinition, {
|
|
key: key,
|
|
title: value.item.title || "Untitled",
|
|
link: value.item.permalink || "#",
|
|
tags: value.item.tags || "",
|
|
categories: value.item.categories || "",
|
|
snippet: snippet || "No preview available",
|
|
});
|
|
searchResults.insertAdjacentHTML("beforeend", output);
|
|
|
|
// Add highlighting after insertion
|
|
for (const snipvalue of snippetHighlights) {
|
|
if (!snipvalue) continue;
|
|
|
|
const summaryElem = document.getElementById(`summary-${key}`);
|
|
if (summaryElem && typeof Mark !== "undefined") {
|
|
try {
|
|
const markInstance = new Mark(summaryElem);
|
|
markInstance.mark(snipvalue);
|
|
} catch (e) {
|
|
console.warn("Error highlighting text:", e);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error rendering search result:", error);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error populating results:", error);
|
|
displayError("There was a problem displaying search results.");
|
|
}
|
|
}
|
|
|
|
function render(templateString, data) {
|
|
try {
|
|
if (!templateString || !data) {
|
|
throw new Error("Invalid template or data");
|
|
}
|
|
|
|
let conditionalMatches, conditionalPattern, copy;
|
|
conditionalPattern = /\$\{\s*isset ([a-zA-Z]*) \s*\}(.*)\$\{\s*end\s*}/g;
|
|
|
|
// Since loop below depends on re.lastIndex, we use a copy to capture any manipulations
|
|
copy = templateString;
|
|
|
|
while ((conditionalMatches = conditionalPattern.exec(templateString)) !== null) {
|
|
if (conditionalMatches.length < 3) continue;
|
|
|
|
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], "");
|
|
}
|
|
}
|
|
|
|
let result = copy;
|
|
|
|
// Now any conditionals removed we can do simple substitution
|
|
for (const key in data) {
|
|
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
const value = data[key];
|
|
if (value !== undefined && value !== null) {
|
|
const find = `\\$\\{\\s*${key}\\s*\\}`;
|
|
const re = new RegExp(find, "g");
|
|
result = result.replace(re, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error("Error rendering template:", error);
|
|
return '<div class="alert alert-danger">Error rendering result</div>';
|
|
}
|
|
}
|