class HydeSearch { debugOutput: boolean = false; searchIndexLocation: string; searchIndex: Array; searchInput = document.getElementById("search-input") as HTMLInputElement; hydeSearchContainer = document.getElementById('hyde-search') as HTMLDivElement; protected searchResultsList: HTMLDListElement; protected searchResultsContainer: HTMLDivElement; constructor(searchIndexLocation: string) { this.searchIndexLocation = searchIndexLocation; } public init(): void { this.debug("Initializing..."); this.loadIndex().then(() => { if (this.debugOutput) { console.log(this.searchIndex); } }); this.searchInput.addEventListener("input", () => { this.search(); }); this.createSearchResultsContainer(); this.debug("Initialized."); } public withDebugOutput(): this { this.debugOutput = true; return this; } public debug(string: string) { if (this.debugOutput) { console.log('HS/Debug: '+ string); } } public async loadIndex(): Promise { this.debug("Loading index..."); const response = await fetch(this.searchIndexLocation, { method: "GET", headers: { "Content-Type": "application/json", } }); if (!response.ok) { throw new Error(`Could not load search index from ${this.searchIndexLocation}`); } this.searchIndex = await response.json(); this.debug("Index loaded."); } public search() { const startTime = window.performance.now(); this.debug("Searching... Got input: " + this.searchInput.value); const searchTerm = this.searchInput.value; // Clear the list this.searchResultsList.innerHTML = ""; // Check if the search term is empty if (searchTerm.length === 0) { return this.displayEnterSearchQuery(); } // Find indexEntries where the search term is in the title or content const searchResults = this.searchIndex.filter((indexEntry) => { return indexEntry["title"].toLowerCase().includes(searchTerm.toLowerCase()) || indexEntry["content"].toLowerCase().includes(searchTerm.toLowerCase()); }); return searchResults.length > 0 ? this.displayResults(searchResults, startTime) : this.displayNoResults(); } protected displayResults(searchResults: any[], startTime) { this.debug("Found "+ searchResults.length +" search results."); // Get the number of matches in all search results const searchTermCount = searchResults.reduce((acc, result) => { return acc + (result["content"].match(new RegExp(this.searchInput.value, "gi")) || []).length; }, 0); this.setSearchStatusMessage("Found " + searchTermCount + " result"+ (searchResults.length > 1 ? "s" : "") + " in " + searchResults.length + " pages."); // Sort results by number of matches searchResults.sort((a, b) => { return (b["content"].match(new RegExp(this.searchInput.value, "gi")) || []).length - (a["content"].match(new RegExp(this.searchInput.value, "gi")) || []).length; }); // Add each result to the list searchResults.forEach((result) => { const resultItem: ResultItem = new ResultItem( result["title"], result["content"], result["destination"], result["slug"], this.searchInput.value ); this.searchResultsList.appendChild(resultItem.createTitleElement()); this.searchResultsList.appendChild(resultItem.createContextElement()); }); const timeString = `${Math.round((((window.performance.now() - startTime) + Number.EPSILON)) * 100) / 100}ms`; this.debug(`Execution time: ${timeString}`); const searchStatusMessage = this.searchResultsContainer.querySelector("p#search-status") as HTMLParagraphElement; searchStatusMessage.innerHTML = searchStatusMessage.innerText + ` ~${timeString}`; } protected displayEnterSearchQuery() { this.setSearchStatusMessage(""); // this.setSearchStatusMessage("Please enter a search query."); } protected displayNoResults() { this.debug("No results."); this.setSearchStatusMessage("No results found."); } protected createSearchResultsContainer() { this.searchResultsContainer = document.createElement("div"); this.searchResultsContainer.id = "search-results"; this.searchResultsContainer.classList.add("hyde-search-results"); this.hydeSearchContainer.appendChild(this.searchResultsContainer); this.searchResultsList = document.createElement("dl"); this.searchResultsList.id = "search-results-list"; this.searchResultsContainer.appendChild(this.searchResultsList); } protected setSearchStatusMessage(message: string) { if (!this.searchResultsContainer.querySelector("p#search-status")) { const searchStatusMessage = document.createElement("p"); searchStatusMessage.id = "search-status"; this.searchResultsContainer.prepend(searchStatusMessage); } const searchStatusMessage = this.searchResultsContainer.querySelector("p#search-status") as HTMLParagraphElement; searchStatusMessage.innerText = message; } } class ResultItem { public title: string; public content: string; public destination: string; public slug: string; public searchTermCount: number; protected currentSearchTerm: string|null; constructor(title: string, content: string, destination: string, slug: string, currentSearchTerm: string | null = null) { this.title = title; this.content = content; this.destination = destination; this.slug = slug; this.currentSearchTerm = currentSearchTerm; this.searchTermCount = this.getSearchTermCount(); } public getSearchTermCount(): number { return this.currentSearchTerm ? this.searchTermCount = (this.content.match(new RegExp(this.currentSearchTerm, "gi")) || []).length : 0; } public createTitleElement(): HTMLLIElement { const resultItem = document.createElement("dt") as HTMLLIElement; resultItem.classList.add("hyde-search-result"); resultItem.id = "search-result-" + this.slug; const resultLink = document.createElement("a") as HTMLAnchorElement; resultLink.href = this.destination; resultLink.innerText = this.title; resultItem.appendChild(resultLink); // Add search term count to result item resultItem.setAttribute('data-matches', this.searchTermCount.toString()); const searchTermCountSpan = document.createElement("span"); searchTermCountSpan.classList.add("search-term-count"); searchTermCountSpan.innerText = ", " + this.searchTermCount + " occurrence" + (this.searchTermCount > 1 ? "s" : "") + " found."; resultItem.appendChild(searchTermCountSpan); return resultItem; } public createContextElement(): HTMLParagraphElement { const resultContext = document.createElement("dd") as HTMLParagraphElement; resultContext.classList.add("hyde-search-context"); resultContext.setAttribute('data-for', "search-result-" + this.slug); this.highlight(resultContext); return resultContext; } protected highlight(resultContext: HTMLParagraphElement) { // Experimental highlighting // Count the number of search term occurrences in the content // Get the position of the first occurrence of the search term const searchTermPosition = this.content.indexOf(this.currentSearchTerm); // Get the position of where the sentence containing the search term starts const sentenceStartPosition = this.content.lastIndexOf(".", searchTermPosition); const sentenceEndPosition = this.content.indexOf(".", searchTermPosition); // Get the sentence containing the search term const sentence = this.content.substring(sentenceStartPosition + 1, sentenceEndPosition + 1); // Sanitize the content string to remove HTML tags (Not indented to be secure as it assumes the JSON is trusted, but instead tries to remove embeds and images.) function getSanitizedContentString() { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', "/": '/', }; return sentence.replace(/[&<>"'/]/ig, (match) => (map[match])) .replace(/<\/?[^>]+(>|$)/g, "").trim(); } const sanitizedContentString = getSanitizedContentString(); // Highlight the search term resultContext.innerHTML = sanitizedContentString.replace(new RegExp(this.currentSearchTerm, "gi"), `${this.currentSearchTerm}`); } }