Deploy PR #1281 preview

This commit is contained in:
GitHub Actions 2025-12-12 19:11:18 +00:00
commit f10481a532
13 changed files with 55266 additions and 0 deletions

View file

@ -0,0 +1,622 @@
// Polyfill for requestIdleCallback for Safari and unsupported browsers
if (typeof window.requestIdleCallback === "undefined") {
window.requestIdleCallback = function (cb) {
var start = Date.now();
var idlePeriod = 50;
return setTimeout(function () {
cb({
didTimeout: false,
timeRemaining: function () {
return Math.max(0, idlePeriod - (Date.now() - start));
},
});
}, 1);
};
window.cancelIdleCallback = function (id) {
clearTimeout(id);
};
}
// Create mobile elements if they don't exist
function createMobileElements() {
// Create mobile sidebar FAB
const mobileFab = document.createElement("button");
mobileFab.className = "mobile-sidebar-fab";
mobileFab.setAttribute("aria-label", "Toggle sidebar menu");
mobileFab.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
`;
// Only show FAB on mobile (max-width: 800px)
function updateFabVisibility() {
if (window.innerWidth > 800) {
if (mobileFab.parentNode) mobileFab.parentNode.removeChild(mobileFab);
} else {
if (!document.body.contains(mobileFab)) {
document.body.appendChild(mobileFab);
}
mobileFab.style.display = "flex";
}
}
updateFabVisibility();
window.addEventListener("resize", updateFabVisibility);
// Create mobile sidebar container
const mobileContainer = document.createElement("div");
mobileContainer.className = "mobile-sidebar-container";
mobileContainer.innerHTML = `
<div class="mobile-sidebar-handle">
<div class="mobile-sidebar-dragger"></div>
</div>
<div class="mobile-sidebar-content">
<!-- Sidebar content will be cloned here -->
</div>
`;
// Create mobile search popup
const mobileSearchPopup = document.createElement("div");
mobileSearchPopup.id = "mobile-search-popup";
mobileSearchPopup.className = "mobile-search-popup";
mobileSearchPopup.innerHTML = `
<div class="mobile-search-container">
<div class="mobile-search-header">
<input type="text" id="mobile-search-input" placeholder="Search..." />
<button id="close-mobile-search" class="close-mobile-search" aria-label="Close search">&times;</button>
</div>
<div id="mobile-search-results" class="mobile-search-results"></div>
</div>
`;
// Insert at end of body so it is not affected by .container flex or stacking context
document.body.appendChild(mobileContainer);
document.body.appendChild(mobileSearchPopup);
// Immediately populate mobile sidebar content if desktop sidebar exists
const desktopSidebar = document.querySelector(".sidebar");
const mobileSidebarContent = mobileContainer.querySelector(
".mobile-sidebar-content",
);
if (desktopSidebar && mobileSidebarContent) {
mobileSidebarContent.innerHTML = desktopSidebar.innerHTML;
}
}
document.addEventListener("DOMContentLoaded", function () {
// Apply sidebar state immediately before DOM rendering
if (localStorage.getItem("sidebar-collapsed") === "true") {
document.documentElement.classList.add("sidebar-collapsed");
document.body.classList.add("sidebar-collapsed");
}
if (!document.querySelector(".mobile-sidebar-fab")) {
createMobileElements();
}
// Desktop Sidebar Toggle
const sidebarToggle = document.querySelector(".sidebar-toggle");
// On page load, sync the state from `documentElement` to `body`
if (document.documentElement.classList.contains("sidebar-collapsed")) {
document.body.classList.add("sidebar-collapsed");
}
if (sidebarToggle) {
sidebarToggle.addEventListener("click", function () {
// Toggle on both elements for consistency
document.documentElement.classList.toggle("sidebar-collapsed");
document.body.classList.toggle("sidebar-collapsed");
// Use documentElement to check state and save to localStorage
const isCollapsed =
document.documentElement.classList.contains("sidebar-collapsed");
localStorage.setItem("sidebar-collapsed", isCollapsed);
});
}
// Make headings clickable for anchor links
const content = document.querySelector(".content");
if (content) {
const headings = content.querySelectorAll("h1, h2, h3, h4, h5, h6");
headings.forEach(function (heading) {
// Generate a valid, unique ID for each heading
if (!heading.id) {
let baseId = heading.textContent
.toLowerCase()
.replace(/[^a-z0-9\s-_]/g, "") // remove invalid chars
.replace(/^[^a-z]+/, "") // remove leading non-letters
.replace(/[\s-_]+/g, "-")
.replace(/^-+|-+$/g, "") // trim leading/trailing dashes
.trim();
if (!baseId) {
baseId = "section";
}
let id = baseId;
let counter = 1;
while (document.getElementById(id)) {
id = `${baseId}-${counter++}`;
}
heading.id = id;
}
// Make the entire heading clickable
heading.addEventListener("click", function (e) {
const id = this.id;
history.pushState(null, null, "#" + id);
// Scroll with offset
const offset = this.getBoundingClientRect().top + window.scrollY - 80;
window.scrollTo({
top: offset,
behavior: "smooth",
});
});
});
}
// Process footnotes
if (content) {
const footnoteContainer = document.querySelector(".footnotes-container");
// Find all footnote references and create a footnotes section
const footnoteRefs = content.querySelectorAll('a[href^="#fn"]');
if (footnoteRefs.length > 0) {
const footnotesDiv = document.createElement("div");
footnotesDiv.className = "footnotes";
const footnotesHeading = document.createElement("h2");
footnotesHeading.textContent = "Footnotes";
footnotesDiv.appendChild(footnotesHeading);
const footnotesList = document.createElement("ol");
footnoteContainer.appendChild(footnotesDiv);
footnotesDiv.appendChild(footnotesList);
// Add footnotes
document.querySelectorAll(".footnote").forEach((footnote) => {
const id = footnote.id;
const content = footnote.innerHTML;
const li = document.createElement("li");
li.id = id;
li.innerHTML = content;
// Add backlink
const backlink = document.createElement("a");
backlink.href = "#fnref:" + id.replace("fn:", "");
backlink.className = "footnote-backlink";
backlink.textContent = "↩";
li.appendChild(backlink);
footnotesList.appendChild(li);
});
}
}
// Copy link functionality
document.querySelectorAll(".copy-link").forEach(function (copyLink) {
copyLink.addEventListener("click", function (e) {
e.preventDefault();
e.stopPropagation();
// Get option ID from parent element
const option = copyLink.closest(".option");
const optionId = option.id;
// Create URL with hash
const url = new URL(window.location.href);
url.hash = optionId;
// Copy to clipboard
navigator.clipboard
.writeText(url.toString())
.then(function () {
// Show feedback
const feedback = copyLink.nextElementSibling;
feedback.style.display = "inline";
// Hide after 2 seconds
setTimeout(function () {
feedback.style.display = "none";
}, 2000);
})
.catch(function (err) {
console.error("Could not copy link: ", err);
});
});
});
// Handle initial hash navigation
function scrollToElement(element) {
if (element) {
const offset = element.getBoundingClientRect().top + window.scrollY - 80;
window.scrollTo({
top: offset,
behavior: "smooth",
});
}
}
if (window.location.hash) {
const targetElement = document.querySelector(window.location.hash);
if (targetElement) {
setTimeout(() => scrollToElement(targetElement), 0);
// Add highlight class for options page
if (targetElement.classList.contains("option")) {
targetElement.classList.add("highlight");
}
}
}
// Mobile Sidebar Functionality
const mobileSidebarContainer = document.querySelector(
".mobile-sidebar-container",
);
const mobileSidebarFab = document.querySelector(".mobile-sidebar-fab");
const mobileSidebarContent = document.querySelector(
".mobile-sidebar-content",
);
const mobileSidebarHandle = document.querySelector(".mobile-sidebar-handle");
const desktopSidebar = document.querySelector(".sidebar");
// Always set up FAB if it exists
if (mobileSidebarFab && mobileSidebarContainer) {
// Populate content if desktop sidebar exists
if (desktopSidebar && mobileSidebarContent) {
mobileSidebarContent.innerHTML = desktopSidebar.innerHTML;
}
const openMobileSidebar = () => {
mobileSidebarContainer.classList.add("active");
mobileSidebarFab.setAttribute("aria-expanded", "true");
mobileSidebarContainer.setAttribute("aria-hidden", "false");
mobileSidebarFab.classList.add("fab-hidden"); // hide FAB when drawer is open
};
const closeMobileSidebar = () => {
mobileSidebarContainer.classList.remove("active");
mobileSidebarFab.setAttribute("aria-expanded", "false");
mobileSidebarContainer.setAttribute("aria-hidden", "true");
mobileSidebarFab.classList.remove("fab-hidden"); // Show FAB when drawer is closed
};
mobileSidebarFab.addEventListener("click", (e) => {
e.stopPropagation();
if (mobileSidebarContainer.classList.contains("active")) {
closeMobileSidebar();
} else {
openMobileSidebar();
}
});
// Only set up drag functionality if handle exists
if (mobileSidebarHandle) {
// Drag functionality
let isDragging = false;
let startY = 0;
let startHeight = 0;
// Cleanup function for drag interruption
function cleanupDrag() {
if (isDragging) {
isDragging = false;
mobileSidebarHandle.style.cursor = "grab";
document.body.style.userSelect = "";
}
}
mobileSidebarHandle.addEventListener("mousedown", (e) => {
isDragging = true;
startY = e.pageY;
startHeight = mobileSidebarContainer.offsetHeight;
mobileSidebarHandle.style.cursor = "grabbing";
document.body.style.userSelect = "none"; // prevent text selection
});
mobileSidebarHandle.addEventListener("touchstart", (e) => {
isDragging = true;
startY = e.touches[0].pageY;
startHeight = mobileSidebarContainer.offsetHeight;
});
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
const deltaY = startY - e.pageY;
const newHeight = startHeight + deltaY;
const vh = window.innerHeight;
const minHeight = vh * 0.15;
const maxHeight = vh * 0.9;
if (newHeight >= minHeight && newHeight <= maxHeight) {
mobileSidebarContainer.style.height = `${newHeight}px`;
}
});
document.addEventListener("touchmove", (e) => {
if (!isDragging) return;
const deltaY = startY - e.touches[0].pageY;
const newHeight = startHeight + deltaY;
const vh = window.innerHeight;
const minHeight = vh * 0.15;
const maxHeight = vh * 0.9;
if (newHeight >= minHeight && newHeight <= maxHeight) {
mobileSidebarContainer.style.height = `${newHeight}px`;
}
});
document.addEventListener("mouseup", cleanupDrag);
document.addEventListener("touchend", cleanupDrag);
window.addEventListener("blur", cleanupDrag);
document.addEventListener("visibilitychange", function () {
if (document.hidden) cleanupDrag();
});
}
// Close on outside click
document.addEventListener("click", (event) => {
if (
mobileSidebarContainer.classList.contains("active") &&
!mobileSidebarContainer.contains(event.target) &&
!mobileSidebarFab.contains(event.target)
) {
closeMobileSidebar();
}
});
// Close on escape key
document.addEventListener("keydown", (event) => {
if (
event.key === "Escape" &&
mobileSidebarContainer.classList.contains("active")
) {
closeMobileSidebar();
}
});
}
// Options filter functionality
const optionsFilter = document.getElementById("options-filter");
if (optionsFilter) {
const optionsContainer = document.querySelector(".options-container");
if (!optionsContainer) return;
// Only inject the style if it doesn't already exist
if (!document.head.querySelector("style[data-options-hidden]")) {
const styleEl = document.createElement("style");
styleEl.setAttribute("data-options-hidden", "");
styleEl.textContent = ".option-hidden{display:none!important}";
document.head.appendChild(styleEl);
}
// Create filter results counter
const filterResults = document.createElement("div");
filterResults.className = "filter-results";
optionsFilter.parentNode.insertBefore(
filterResults,
optionsFilter.nextSibling,
);
// Detect if we're on a mobile device
const isMobile =
window.innerWidth < 768 || /Mobi|Android/i.test(navigator.userAgent);
// Cache all option elements and their searchable content
const options = Array.from(document.querySelectorAll(".option"));
const totalCount = options.length;
// Store the original order of option elements
const originalOptionOrder = options.slice();
// Pre-process and optimize searchable content
const optionsData = options.map((option) => {
const nameElem = option.querySelector(".option-name");
const descriptionElem = option.querySelector(".option-description");
const id = option.id ? option.id.toLowerCase() : "";
const name = nameElem ? nameElem.textContent.toLowerCase() : "";
const description = descriptionElem
? descriptionElem.textContent.toLowerCase()
: "";
// Extract keywords for faster searching
const keywords = (id + " " + name + " " + description)
.toLowerCase()
.split(/\s+/)
.filter((word) => word.length > 1);
return {
element: option,
id,
name,
description,
keywords,
searchText: (id + " " + name + " " + description).toLowerCase(),
};
});
// Chunk size and rendering variables
const CHUNK_SIZE = isMobile ? 15 : 40;
let pendingRender = null;
let currentChunk = 0;
let itemsToProcess = [];
function debounce(func, wait) {
let timeout;
return function () {
const context = this;
const args = arguments;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
}
// Process options in chunks to prevent UI freezing
function processNextChunk() {
const startIdx = currentChunk * CHUNK_SIZE;
const endIdx = Math.min(startIdx + CHUNK_SIZE, itemsToProcess.length);
if (startIdx < itemsToProcess.length) {
// Process current chunk
for (let i = startIdx; i < endIdx; i++) {
const item = itemsToProcess[i];
if (item.visible) {
item.element.classList.remove("option-hidden");
} else {
item.element.classList.add("option-hidden");
}
}
currentChunk++;
pendingRender = requestAnimationFrame(processNextChunk);
} else {
// Finished processing all chunks
pendingRender = null;
currentChunk = 0;
itemsToProcess = [];
// Update counter at the very end for best performance
if (filterResults.visibleCount !== undefined) {
if (filterResults.visibleCount < totalCount) {
filterResults.textContent = `Showing ${filterResults.visibleCount} of ${totalCount} options`;
filterResults.style.display = "block";
} else {
filterResults.style.display = "none";
}
}
}
}
function filterOptions() {
const searchTerm = optionsFilter.value.toLowerCase().trim();
if (pendingRender) {
cancelAnimationFrame(pendingRender);
pendingRender = null;
}
currentChunk = 0;
itemsToProcess = [];
if (searchTerm === "") {
// Restore original DOM order when filter is cleared
const fragment = document.createDocumentFragment();
originalOptionOrder.forEach((option) => {
option.classList.remove("option-hidden");
fragment.appendChild(option);
});
optionsContainer.appendChild(fragment);
filterResults.style.display = "none";
return;
}
const searchTerms = searchTerm
.split(/\s+/)
.filter((term) => term.length > 0);
let visibleCount = 0;
const titleMatches = [];
const descMatches = [];
optionsData.forEach((data) => {
let isTitleMatch = false;
let isDescMatch = false;
if (searchTerms.length === 1) {
const term = searchTerms[0];
isTitleMatch = data.name.includes(term);
isDescMatch = !isTitleMatch && data.description.includes(term);
} else {
isTitleMatch = searchTerms.every((term) => data.name.includes(term));
isDescMatch =
!isTitleMatch &&
searchTerms.every((term) => data.description.includes(term));
}
if (isTitleMatch) {
titleMatches.push(data);
} else if (isDescMatch) {
descMatches.push(data);
}
});
if (searchTerms.length === 1) {
const term = searchTerms[0];
titleMatches.sort(
(a, b) => a.name.indexOf(term) - b.name.indexOf(term),
);
descMatches.sort(
(a, b) => a.description.indexOf(term) - b.description.indexOf(term),
);
}
itemsToProcess = [];
titleMatches.forEach((data) => {
visibleCount++;
itemsToProcess.push({ element: data.element, visible: true });
});
descMatches.forEach((data) => {
visibleCount++;
itemsToProcess.push({ element: data.element, visible: true });
});
optionsData.forEach((data) => {
if (!itemsToProcess.some((item) => item.element === data.element)) {
itemsToProcess.push({ element: data.element, visible: false });
}
});
// Reorder DOM so all title matches, then desc matches, then hidden
const fragment = document.createDocumentFragment();
itemsToProcess.forEach((item) => {
fragment.appendChild(item.element);
});
optionsContainer.appendChild(fragment);
filterResults.visibleCount = visibleCount;
pendingRender = requestAnimationFrame(processNextChunk);
}
// Use different debounce times for desktop vs mobile
const debouncedFilter = debounce(filterOptions, isMobile ? 200 : 100);
// Set up event listeners
optionsFilter.addEventListener("input", debouncedFilter);
optionsFilter.addEventListener("change", filterOptions);
// Allow clearing with Escape key
optionsFilter.addEventListener("keydown", function (e) {
if (e.key === "Escape") {
optionsFilter.value = "";
filterOptions();
}
});
// Handle visibility changes
document.addEventListener("visibilitychange", function () {
if (!document.hidden && optionsFilter.value) {
filterOptions();
}
});
// Initially trigger filter if there's a value
if (optionsFilter.value) {
filterOptions();
}
// Pre-calculate heights for smoother scrolling
if (isMobile && totalCount > 50) {
requestIdleCallback(() => {
const sampleOption = options[0];
if (sampleOption) {
const height = sampleOption.offsetHeight;
if (height > 0) {
options.forEach((opt) => {
opt.style.containIntrinsicSize = `0 ${height}px`;
});
}
}
});
}
}
});

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,64 @@
self.onmessage = function(e) {
const { messageId, type, data } = e.data;
const respond = (type, data) => {
self.postMessage({ messageId, type, data });
};
const respondError = (error) => {
self.postMessage({ messageId, type: 'error', error: error.message || String(error) });
};
try {
if (type === 'tokenize') {
const tokens = (typeof data === 'string' ? data : '')
.toLowerCase()
.match(/\b[a-zA-Z0-9_-]+\b/g) || []
.filter(word => word.length > 2);
const uniqueTokens = Array.from(new Set(tokens));
respond('tokens', uniqueTokens);
}
if (type === 'search') {
const { documents, query, limit } = data;
const searchTerms = (typeof query === 'string' ? query : '')
.toLowerCase()
.match(/\b[a-zA-Z0-9_-]+\b/g) || []
.filter(word => word.length > 2);
const docScores = new Map();
// Pre-compute lower-case terms once
const lowerSearchTerms = searchTerms.map(term => term.toLowerCase());
// Pre-compute lower-case strings for each document
const processedDocs = documents.map((doc, docId) => ({
docId,
title: doc.title,
content: doc.content,
lowerTitle: doc.title.toLowerCase(),
lowerContent: doc.content.toLowerCase()
}));
lowerSearchTerms.forEach(lowerTerm => {
processedDocs.forEach(({ docId, title, content, lowerTitle, lowerContent }) => {
if (lowerTitle.includes(lowerTerm) || lowerContent.includes(lowerTerm)) {
const score = lowerTitle === lowerTerm ? 30 :
lowerTitle.includes(lowerTerm) ? 10 : 2;
docScores.set(docId, (docScores.get(docId) || 0) + score);
}
});
});
const results = Array.from(docScores.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([docId, score]) => ({ ...documents[docId], score }));
respond('results', results);
}
} catch (error) {
respondError(error);
}
};

View file

@ -0,0 +1,786 @@
if (!window.searchNamespace) window.searchNamespace = {};
class SearchEngine {
constructor() {
this.documents = [];
this.tokenMap = new Map();
this.isLoaded = false;
this.loadError = false;
this.useWebWorker = typeof Worker !== 'undefined' && searchWorker !== null;
this.fullDocuments = null; // for lazy loading
this.rootPath = window.searchNamespace?.rootPath || '';
}
// Load search data from JSON
async loadData() {
if (this.isLoaded && !this.loadError) return;
// Clear previous error state on retry
this.loadError = false;
try {
// Load JSON data, try multiple possible paths
// FIXME: There is only one possible path for now, and this search data is guaranteed
// to generate at this location, but we'll want to extend this in the future.
const possiblePaths = ["/assets/search-data.json"];
let response = null;
let usedPath = "";
for (const path of possiblePaths) {
try {
const testResponse = await fetch(path);
if (testResponse.ok) {
response = testResponse;
usedPath = path;
break;
}
} catch (e) {
// Continue to next path
}
}
if (!response) {
throw new Error("Search data file not found at any expected location");
}
console.log(`Loading search data from: ${usedPath}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Use optimized JSON parsing for large files
const documents = await this.parseLargeJSON(response);
if (!Array.isArray(documents)) {
throw new Error("Invalid search data format");
}
this.initializeFromDocuments(documents);
this.isLoaded = true;
console.log(`Loaded ${documents.length} documents for search`);
} catch (error) {
console.error("Error loading search data:", error);
this.documents = [];
this.tokenMap.clear();
this.loadError = true;
}
}
// Initialize from documents array
async initializeFromDocuments(documents) {
if (!Array.isArray(documents)) {
console.error("Invalid documents format:", typeof documents);
this.documents = [];
} else {
this.documents = documents;
console.log(`Initialized with ${documents.length} documents`);
}
try {
await this.buildTokenMap();
} catch (error) {
console.error("Error building token map:", error);
}
}
// Initialize from search index structure
initializeIndex(indexData) {
this.documents = indexData.documents || [];
this.tokenMap = new Map(Object.entries(indexData.tokenMap || {}));
}
// Build token map
// This is helpful for faster searching with progressive loading
buildTokenMap() {
return new Promise((resolve, reject) => {
this.tokenMap.clear();
if (!Array.isArray(this.documents)) {
console.error("No documents to build token map");
resolve();
return;
}
const totalDocs = this.documents.length;
let processedDocs = 0;
try {
// Process in chunks to avoid blocking UI
const processChunk = (startIndex, chunkSize) => {
try {
const endIndex = Math.min(startIndex + chunkSize, totalDocs);
for (let i = startIndex; i < endIndex; i++) {
const doc = this.documents[i];
if (!doc || typeof doc.title !== 'string' || typeof doc.content !== 'string') {
console.warn(`Invalid document at index ${i}:`, doc);
continue;
}
const tokens = this.tokenize(doc.title + " " + doc.content);
tokens.forEach(token => {
if (!this.tokenMap.has(token)) {
this.tokenMap.set(token, []);
}
this.tokenMap.get(token).push(i);
});
processedDocs++;
}
// Update progress and yield control
if (endIndex < totalDocs) {
setTimeout(() => processChunk(endIndex, chunkSize), 0);
} else {
console.log(`Built token map with ${this.tokenMap.size} unique tokens from ${processedDocs} documents`);
resolve();
}
} catch (error) {
reject(error);
}
};
// Start processing with small chunks
processChunk(0, 100);
} catch (error) {
reject(error);
}
});
}
// Tokenize text into searchable terms
tokenize(text) {
const tokens = new Set();
const words = text.toLowerCase().match(/\b[a-zA-Z0-9_-]+\b/g) || [];
words.forEach(word => {
if (word.length > 2) {
tokens.add(word);
}
});
return Array.from(tokens);
}
// Advanced search with ranking
async search(query, limit = 10) {
if (!query.trim()) return [];
// Wait for data to be loaded
if (!this.isLoaded) {
await this.loadData();
}
if (!this.isLoaded || this.documents.length === 0) {
console.log("Search data not available");
return [];
}
const searchTerms = this.tokenize(query);
if (searchTerms.length === 0) return [];
// Fallback to basic search if token map is empty
if (this.tokenMap.size === 0) {
return this.fallbackSearch(query, limit);
}
// Use Web Worker for large datasets to avoid blocking UI
if (this.useWebWorker && this.documents.length > 1000) {
return await this.searchWithWorker(query, limit);
}
// For very large datasets, implement lazy loading with candidate docIds
if (this.documents.length > 10000) {
const candidateDocIds = new Set();
searchTerms.forEach(term => {
const docIds = this.tokenMap.get(term) || [];
docIds.forEach(id => candidateDocIds.add(id));
});
const docIds = Array.from(candidateDocIds);
return await this.lazyLoadDocuments(docIds, limit);
}
const docScores = new Map();
searchTerms.forEach(term => {
const docIds = this.tokenMap.get(term) || [];
docIds.forEach(docId => {
const doc = this.documents[docId];
if (!doc) return;
const currentScore = docScores.get(docId) || 0;
// Calculate score based on term position and importance
let score = 1;
// Title matches get higher score
if (doc.title.toLowerCase().includes(term)) {
score += 10;
// Exact title match gets even higher score
if (doc.title.toLowerCase() === term) {
score += 20;
}
}
// Content matches
if (doc.content.toLowerCase().includes(term)) {
score += 2;
}
// Boost for multiple term matches
docScores.set(docId, currentScore + score);
});
});
// Sort by score and return top results
const scoredResults = Array.from(docScores.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit);
return scoredResults
.map(([docId, score]) => ({
...this.documents[docId],
score
}));
}
// Generate search preview with highlighting
generatePreview(content, query, maxLength = 150) {
const lowerContent = content.toLowerCase();
let bestIndex = -1;
let bestScore = 0;
let bestMatch = "";
// Find the best match position
const queryWords = this.tokenize(query);
queryWords.forEach(word => {
const index = lowerContent.indexOf(word);
if (index !== -1) {
const score = word.length; // longer words get higher priority
if (score > bestScore) {
bestScore = score;
bestIndex = index;
bestMatch = word;
}
}
});
if (bestIndex === -1) {
return this.escapeHtml(content.slice(0, maxLength)) + "...";
}
const start = Math.max(0, bestIndex - 50);
const end = Math.min(content.length, bestIndex + bestMatch.length + 50);
let preview = content.slice(start, end);
if (start > 0) preview = "..." + preview;
if (end < content.length) preview += "...";
// Escape HTML first, then highlight
preview = this.escapeHtml(preview);
preview = this.highlightTerms(preview, queryWords);
return preview;
}
// Escape HTML to prevent XSS
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Highlight search terms in text
highlightTerms(text, terms) {
let highlighted = text;
// Sort terms by length (longer first) to avoid overlapping highlights
const sortedTerms = [...terms].sort((a, b) => b.length - a.length);
sortedTerms.forEach(term => {
const regex = new RegExp(`(${this.escapeRegex(term)})`, 'gi');
highlighted = highlighted.replace(regex, '<mark>$1</mark>');
});
return highlighted;
}
// Web Worker search for large datasets
async searchWithWorker(query, limit) {
return new Promise((resolve, reject) => {
const messageId = `search_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const timeout = setTimeout(() => {
cleanup();
reject(new Error('Web Worker search timeout'));
}, 5000); // 5 second timeout
const handleMessage = (e) => {
if (e.data.messageId !== messageId) return;
clearTimeout(timeout);
cleanup();
if (e.data.type === 'results') {
resolve(e.data.data);
} else if (e.data.type === 'error') {
reject(new Error(e.data.error || 'Unknown worker error'));
}
};
const handleError = (error) => {
clearTimeout(timeout);
cleanup();
reject(error);
};
const cleanup = () => {
searchWorker.removeEventListener('message', handleMessage);
searchWorker.removeEventListener('error', handleError);
};
searchWorker.addEventListener('message', handleMessage);
searchWorker.addEventListener('error', handleError);
searchWorker.postMessage({
messageId,
type: 'search',
data: { documents: this.documents, query, limit }
});
});
}
// Escape regex special characters
escapeRegex(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Resolve path relative to current page location
resolvePath(path) {
// If path already starts with '/', it's absolute from domain root
if (path.startsWith('/')) {
return path;
}
// If path starts with '#', it's a fragment on current page
if (path.startsWith('#')) {
return path;
}
// Prepend root path for relative navigation
return this.rootPath + path;
}
// Optimized JSON parser for large files
async parseLargeJSON(response) {
const contentLength = response.headers.get('content-length');
// For small files, use regular JSON parsing
if (!contentLength || parseInt(contentLength) < 1024 * 1024) { // < 1MB
return await response.json();
}
// For large files, use streaming approach
console.log(`Large search file detected (${contentLength} bytes), using streaming parser`);
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process in chunks to avoid blocking main thread
if (buffer.length > 100 * 1024) { // 100KB chunks
await new Promise(resolve => setTimeout(resolve, 0));
}
}
return JSON.parse(buffer);
}
// Lazy loading for search results
async lazyLoadDocuments(docIds, limit = 10) {
if (!this.fullDocuments) {
// Store full documents separately for memory efficiency
this.fullDocuments = this.documents;
// Create lightweight index documents
this.documents = this.documents.map(doc => ({
id: doc.id,
title: doc.title,
path: doc.path
}));
}
return docIds.slice(0, limit).map(id => this.fullDocuments[id]);
}
// Fallback search method (simple string matching)
fallbackSearch(query, limit = 10) {
const lowerQuery = query.toLowerCase();
const results = this.documents
.map(doc => {
const titleMatch = doc.title.toLowerCase().indexOf(lowerQuery);
const contentMatch = doc.content.toLowerCase().indexOf(lowerQuery);
let score = 0;
if (titleMatch !== -1) {
score += 10;
if (doc.title.toLowerCase() === lowerQuery) {
score += 20;
}
}
if (contentMatch !== -1) {
score += 2;
}
return { doc, score, titleMatch, contentMatch };
})
.filter(item => item.score > 0)
.sort((a, b) => {
if (a.score !== b.score) return b.score - a.score;
if (a.titleMatch !== b.titleMatch) return a.titleMatch - b.titleMatch;
return a.contentMatch - b.contentMatch;
})
.slice(0, limit)
.map(item => ({ ...item.doc, score: item.score }));
return results;
}
}
// Web Worker for background search processing
// This is CLEARLY the best way to do it lmao.
// Create Web Worker if supported
let searchWorker = null;
if (typeof Worker !== 'undefined') {
try {
searchWorker = new Worker('/assets/search-worker.js');
console.log('Web Worker initialized for background search');
} catch (error) {
console.warn('Web Worker creation failed, using main thread:', error);
}
}
// Global search engine instance
window.searchNamespace.engine = new SearchEngine();
// Mobile search timeout for debouncing
let mobileSearchTimeout = null;
// Legacy search for backward compatibility
// This could be removed, but I'm emotionally attached to it
// and it could be used as a fallback.
function filterSearchResults(data, searchTerm, limit = 10) {
return data
.filter(
(doc) =>
doc.title.toLowerCase().includes(searchTerm) ||
doc.content.toLowerCase().includes(searchTerm),
)
.slice(0, limit);
}
document.addEventListener("DOMContentLoaded", function() {
// Initialize search engine immediately
window.searchNamespace.engine.loadData().then(() => {
console.log("Search data loaded successfully");
}).catch(error => {
console.error("Failed to initialize search:", error);
});
// Search page specific functionality
const searchPageInput = document.getElementById("search-page-input");
if (searchPageInput) {
// Set up event listener
searchPageInput.addEventListener("input", function() {
performSearch(this.value);
});
// Perform search if URL has query
const params = new URLSearchParams(window.location.search);
const query = params.get("q");
if (query) {
searchPageInput.value = query;
performSearch(query);
}
}
// Desktop Sidebar Toggle
const searchInput = document.getElementById("search-input");
if (searchInput) {
const searchResults = document.getElementById("search-results");
searchInput.addEventListener("input", async function() {
const searchTerm = this.value.trim();
if (searchTerm.length < 2) {
searchResults.innerHTML = "";
searchResults.style.display = "none";
return;
}
// Show loading state
searchResults.innerHTML = '<div class="search-result-item">Loading...</div>';
searchResults.style.display = "block";
try {
const results = await window.searchNamespace.engine.search(searchTerm, 8);
if (results.length > 0) {
searchResults.innerHTML = results
.map(
(doc) => {
const highlightedTitle = window.searchNamespace.engine.highlightTerms(
doc.title,
window.searchNamespace.engine.tokenize(searchTerm)
);
const resolvedPath = window.searchNamespace.engine.resolvePath(doc.path);
return `
<div class="search-result-item">
<a href="${resolvedPath}">${highlightedTitle}</a>
</div>
`;
},
)
.join("");
searchResults.style.display = "block";
} else {
searchResults.innerHTML =
'<div class="search-result-item">No results found</div>';
searchResults.style.display = "block";
}
} catch (error) {
console.error("Search error:", error);
searchResults.innerHTML =
'<div class="search-result-item">Search unavailable</div>';
searchResults.style.display = "block";
}
});
// Hide results when clicking outside
document.addEventListener("click", function(event) {
if (
!searchInput.contains(event.target) &&
!searchResults.contains(event.target)
) {
searchResults.style.display = "none";
}
});
// Focus search when pressing slash key
document.addEventListener("keydown", function(event) {
if (event.key === "/" && document.activeElement !== searchInput) {
event.preventDefault();
searchInput.focus();
}
});
}
// Mobile search functionality
// This detects mobile viewport and adds click behavior
function isMobile() {
return window.innerWidth <= 800;
}
if (searchInput) {
// Add mobile search behavior
searchInput.addEventListener("click", function(e) {
if (isMobile()) {
e.preventDefault();
e.stopPropagation();
openMobileSearch();
}
// On desktop, let the normal click behavior work (focus the input)
});
// Prevent typing on mobile (input should only open popup)
searchInput.addEventListener("keydown", function(e) {
if (isMobile()) {
e.preventDefault();
openMobileSearch();
}
});
}
// Mobile search popup functionality
let mobileSearchPopup = document.getElementById("mobile-search-popup");
let mobileSearchInput = document.getElementById("mobile-search-input");
let mobileSearchResults = document.getElementById("mobile-search-results");
const closeMobileSearchBtn = document.getElementById("close-mobile-search");
function openMobileSearch() {
if (mobileSearchPopup) {
mobileSearchPopup.classList.add("active");
// Focus the input after a small delay to ensure the popup is visible
setTimeout(() => {
if (mobileSearchInput) {
mobileSearchInput.focus();
}
}, 100);
}
}
function closeMobileSearch() {
if (mobileSearchPopup) {
mobileSearchPopup.classList.remove("active");
if (mobileSearchInput) {
mobileSearchInput.value = "";
}
if (mobileSearchResults) {
mobileSearchResults.innerHTML = "";
mobileSearchResults.style.display = "none";
}
}
}
if (closeMobileSearchBtn) {
closeMobileSearchBtn.addEventListener("click", closeMobileSearch);
}
// Close mobile search when clicking outside
document.addEventListener("click", function(event) {
if (
mobileSearchPopup &&
mobileSearchPopup.classList.contains("active") &&
!mobileSearchPopup.contains(event.target) &&
!searchInput.contains(event.target)
) {
closeMobileSearch();
}
});
// Close mobile search on escape key
document.addEventListener("keydown", function(event) {
if (
event.key === "Escape" &&
mobileSearchPopup &&
mobileSearchPopup.classList.contains("active")
) {
closeMobileSearch();
}
});
// Mobile search input
if (mobileSearchInput && mobileSearchResults) {
function handleMobileSearchInput() {
clearTimeout(mobileSearchTimeout);
const searchTerm = mobileSearchInput.value.trim();
if (searchTerm.length < 2) {
mobileSearchResults.innerHTML = "";
mobileSearchResults.style.display = "none";
return;
}
mobileSearchTimeout = setTimeout(async () => {
// Verify the input still matches before proceeding
if (mobileSearchInput.value.trim() !== searchTerm) return;
// Show loading state
mobileSearchResults.innerHTML = '<div class="search-result-item">Loading...</div>';
mobileSearchResults.style.display = "block";
try {
const results = await window.searchNamespace.engine.search(searchTerm, 8);
// Verify again after async operation
if (mobileSearchInput.value.trim() !== searchTerm) return;
if (results.length > 0) {
mobileSearchResults.innerHTML = results
.map(
(doc) => {
const highlightedTitle = window.searchNamespace.engine.highlightTerms(
doc.title,
window.searchNamespace.engine.tokenize(searchTerm)
);
const resolvedPath = window.searchNamespace.engine.resolvePath(doc.path);
return `
<div class="search-result-item">
<a href="${resolvedPath}">${highlightedTitle}</a>
</div>
`;
},
)
.join("");
mobileSearchResults.style.display = "block";
} else {
mobileSearchResults.innerHTML =
'<div class="search-result-item">No results found</div>';
mobileSearchResults.style.display = "block";
}
} catch (error) {
console.error("Mobile search error:", error);
// Verify once more
if (mobileSearchInput.value.trim() !== searchTerm) return;
mobileSearchResults.innerHTML =
'<div class="search-result-item">Search unavailable</div>';
mobileSearchResults.style.display = "block";
}
}, 300);
}
mobileSearchInput.addEventListener("input", handleMobileSearchInput);
}
// Handle window resize to update mobile behavior
window.addEventListener("resize", function() {
// Close mobile search if window is resized to desktop size
if (
!isMobile() &&
mobileSearchPopup &&
mobileSearchPopup.classList.contains("active")
) {
closeMobileSearch();
}
});
});
async function performSearch(query) {
query = query.trim();
const resultsContainer = document.getElementById("search-page-results");
if (query.length < 2) {
resultsContainer.innerHTML =
"<p>Please enter at least 2 characters to search</p>";
return;
}
// Show loading state
resultsContainer.innerHTML = "<p>Searching...</p>";
try {
const results = await window.searchNamespace.engine.search(query, 50);
// Display results
if (results.length > 0) {
let html = '<ul class="search-results-list">';
const queryTerms = window.searchNamespace.engine.tokenize(query);
for (const result of results) {
const highlightedTitle = window.searchNamespace.engine.highlightTerms(result.title, queryTerms);
const preview = window.searchNamespace.engine.generatePreview(result.content, query);
const resolvedPath = window.searchNamespace.engine.resolvePath(result.path);
html += `<li class="search-result-item">
<a href="${resolvedPath}">
<div class="search-result-title">${highlightedTitle}</div>
<div class="search-result-preview">${preview}</div>
</a>
</li>`;
}
html += "</ul>";
resultsContainer.innerHTML = html;
} else {
resultsContainer.innerHTML = "<p>No results found</p>";
}
// Update URL with query
const url = new URL(window.location.href);
url.searchParams.set("q", query);
window.history.replaceState({}, "", url.toString());
} catch (error) {
console.error("Search error:", error);
resultsContainer.innerHTML = "<p>Search temporarily unavailable</p>";
}
}

File diff suppressed because it is too large Load diff