mirror of
https://github.com/NotAShelf/nvf.git
synced 2025-12-13 15:41:03 +00:00
deploy: 2fe8be4b6c
This commit is contained in:
parent
43ce5a325e
commit
1d46a2cf41
43 changed files with 33 additions and 165801 deletions
1
CNAME
1
CNAME
|
|
@ -1 +0,0 @@
|
|||
nvf.notashelf.dev
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,622 +0,0 @@
|
|||
// 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">×</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
|
|
@ -1,64 +0,0 @@
|
|||
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);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,786 +0,0 @@
|
|||
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
|
|
@ -1,133 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Known Issues and Quirks</title>
|
||||
|
||||
<script>
|
||||
// Apply sidebar state immediately to prevent flash
|
||||
(function () {
|
||||
if (localStorage.getItem("sidebar-collapsed") === "true") {
|
||||
document.documentElement.classList.add("sidebar-collapsed");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="assets/style.css" />
|
||||
<script defer src="assets/main.js"></script>
|
||||
|
||||
<script>
|
||||
window.searchNamespace = window.searchNamespace || {};
|
||||
window.searchNamespace.rootPath = "";
|
||||
</script>
|
||||
<script defer src="assets/search.js"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<h1 class="site-title">
|
||||
<a href="index.html">NVF</a>
|
||||
</h1>
|
||||
|
||||
<nav class="header-nav">
|
||||
<ul>
|
||||
<li >
|
||||
<a href="options.html">Options</a>
|
||||
</li>
|
||||
|
||||
<li><a href="search.html">Search</a></li>
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-input" placeholder="Search..." />
|
||||
<div id="search-results" class="search-results"></div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<div class="sidebar-toggle" aria-label="Toggle sidebar">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<nav class="sidebar">
|
||||
<div class="docs-nav">
|
||||
<h2>Documents</h2>
|
||||
<ul>
|
||||
<li><a href="index.html">Introduction</a></li>
|
||||
<li><a href="configuring.html">Configuring nvf</a></li>
|
||||
<li><a href="hacking.html">Hacking nvf</a></li>
|
||||
<li><a href="tips.html">Helpful Tips</a></li>
|
||||
<li><a href="quirks.html">Known Issues and Quirks</a></li>
|
||||
<li><a href="release-notes.html">Release Notes</a></li>
|
||||
<li><a href="search.html">Search</a></li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="toc">
|
||||
<h2>Contents</h2>
|
||||
<ul class="toc-list">
|
||||
<li><a href="#ch-known-issues-quirks">Known Issues and Quirks</a>
|
||||
<ul><li><a href="#ch-quirks-nodejs">NodeJS</a>
|
||||
<ul><li><a href="#sec-eslint-plugin-prettier">eslint-plugin-prettier</a>
|
||||
</ul><li><a href="#ch-bugs-suggestions">Bugs & Suggestions</a>
|
||||
</li></ul></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="content"><html><head></head><body><h1 id="ch-known-issues-quirks">Known Issues and Quirks</h1>
|
||||
<p>At times, certain plugins and modules may refuse to play nicely with your setup,
|
||||
be it a result of generating Lua from Nix, or the state of packaging. This page,
|
||||
in turn, will list any known modules or plugins that are known to misbehave, and
|
||||
possible workarounds that you may apply.</p>
|
||||
<h2 id="ch-quirks-nodejs">NodeJS</h2>
|
||||
<h3 id="sec-eslint-plugin-prettier">eslint-plugin-prettier</h3>
|
||||
<p>When working with NodeJS, everything works as expected, but some projects have
|
||||
settings that can fool nvf.</p>
|
||||
<p>If <a href="https://github.com/prettier/eslint-plugin-prettier">this plugin</a> or similar
|
||||
is included, you might get a situation where your eslint configuration diagnoses
|
||||
your formatting according to its own config (usually <code>.eslintrc.js</code>).</p>
|
||||
<p>The issue there is your formatting is made via prettierd.</p>
|
||||
<p>This results in auto-formatting relying on your prettier config, while your
|
||||
eslint config diagnoses formatting
|
||||
<a href="https://prettier.io/docs/en/comparison.html">which it's not supposed to</a>)</p>
|
||||
<p>In the end, you get discrepancies between what your editor does and what it
|
||||
wants.</p>
|
||||
<p>Solutions are:</p>
|
||||
<ol>
|
||||
<li>Don't add a formatting config to eslint, and separate prettier and eslint.</li>
|
||||
<li>PR this repo to add an ESLint formatter and configure nvf to use it.</li>
|
||||
</ol>
|
||||
<h2 id="ch-bugs-suggestions">Bugs & Suggestions</h2>
|
||||
<p>Some quirks are not exactly quirks, but bugs in the module systeme. If you
|
||||
notice any issues with nvf, or this documentation, then please consider
|
||||
reporting them over at the <a href="https://github.com/notashelf/nvf/issues">issue tracker</a>. Issues tab, in addition to the
|
||||
<a href="https://github.com/notashelf/nvf/discussions">discussions tab</a> is a good place as any to request new features.</p>
|
||||
<p>You may also consider submitting bugfixes, feature additions and upstreamed
|
||||
changes that you think are critical over at the <a href="https://github.com/notashelf/nvf/pulls">pull requests tab</a>.</p>
|
||||
</body></html></main>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Generated with ndg</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,106 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NVF - Search</title>
|
||||
|
||||
<script>
|
||||
// Apply sidebar state immediately to prevent flash
|
||||
(function () {
|
||||
if (localStorage.getItem("sidebar-collapsed") === "true") {
|
||||
document.documentElement.classList.add("sidebar-collapsed");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="assets/style.css" />
|
||||
<script defer src="assets/main.js"></script>
|
||||
<script defer src="assets/search.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<h1 class="site-title">
|
||||
<a href="index.html">NVF</a>
|
||||
</h1>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<ul>
|
||||
<li >
|
||||
<a href="options.html">Options</a>
|
||||
</li>
|
||||
|
||||
<li><a href="search.html">Search</a></li>
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-input" placeholder="Search..." />
|
||||
<div id="search-results" class="search-results"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<div class="sidebar-toggle" aria-label="Toggle sidebar">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<nav id="sidebar" class="sidebar">
|
||||
<div class="docs-nav">
|
||||
<h2>Documents</h2>
|
||||
<ul>
|
||||
<li><a href="index.html">Introduction</a></li>
|
||||
<li><a href="configuring.html">Configuring nvf</a></li>
|
||||
<li><a href="hacking.html">Hacking nvf</a></li>
|
||||
<li><a href="tips.html">Helpful Tips</a></li>
|
||||
<li><a href="quirks.html">Known Issues and Quirks</a></li>
|
||||
<li><a href="release-notes.html">Release Notes</a></li>
|
||||
<li><a href="search.html">Search</a></li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="toc">
|
||||
<h2>Contents</h2>
|
||||
<ul class="toc-list">
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="content">
|
||||
<h1>Search</h1>
|
||||
<div class="search-page">
|
||||
<div class="search-form">
|
||||
<input
|
||||
type="text"
|
||||
id="search-page-input"
|
||||
placeholder="Search..."
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div id="search-page-results" class="search-page-results"></div>
|
||||
</div>
|
||||
<div class="footnotes-container">
|
||||
<!-- Footnotes will be appended here -->
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Generated with ndg</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,622 +0,0 @@
|
|||
// 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">×</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
|
|
@ -1,64 +0,0 @@
|
|||
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);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,786 +0,0 @@
|
|||
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
|
|
@ -1,133 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Known Issues and Quirks</title>
|
||||
|
||||
<script>
|
||||
// Apply sidebar state immediately to prevent flash
|
||||
(function () {
|
||||
if (localStorage.getItem("sidebar-collapsed") === "true") {
|
||||
document.documentElement.classList.add("sidebar-collapsed");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="assets/style.css" />
|
||||
<script defer src="assets/main.js"></script>
|
||||
|
||||
<script>
|
||||
window.searchNamespace = window.searchNamespace || {};
|
||||
window.searchNamespace.rootPath = "";
|
||||
</script>
|
||||
<script defer src="assets/search.js"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<h1 class="site-title">
|
||||
<a href="index.html">NVF</a>
|
||||
</h1>
|
||||
|
||||
<nav class="header-nav">
|
||||
<ul>
|
||||
<li >
|
||||
<a href="options.html">Options</a>
|
||||
</li>
|
||||
|
||||
<li><a href="search.html">Search</a></li>
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-input" placeholder="Search..." />
|
||||
<div id="search-results" class="search-results"></div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<div class="sidebar-toggle" aria-label="Toggle sidebar">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<nav class="sidebar">
|
||||
<div class="docs-nav">
|
||||
<h2>Documents</h2>
|
||||
<ul>
|
||||
<li><a href="index.html">Introduction</a></li>
|
||||
<li><a href="configuring.html">Configuring nvf</a></li>
|
||||
<li><a href="hacking.html">Hacking nvf</a></li>
|
||||
<li><a href="tips.html">Helpful Tips</a></li>
|
||||
<li><a href="quirks.html">Known Issues and Quirks</a></li>
|
||||
<li><a href="release-notes.html">Release Notes</a></li>
|
||||
<li><a href="search.html">Search</a></li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="toc">
|
||||
<h2>Contents</h2>
|
||||
<ul class="toc-list">
|
||||
<li><a href="#ch-known-issues-quirks">Known Issues and Quirks</a>
|
||||
<ul><li><a href="#ch-quirks-nodejs">NodeJS</a>
|
||||
<ul><li><a href="#sec-eslint-plugin-prettier">eslint-plugin-prettier</a>
|
||||
</ul><li><a href="#ch-bugs-suggestions">Bugs & Suggestions</a>
|
||||
</li></ul></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="content"><html><head></head><body><h1 id="ch-known-issues-quirks">Known Issues and Quirks</h1>
|
||||
<p>At times, certain plugins and modules may refuse to play nicely with your setup,
|
||||
be it a result of generating Lua from Nix, or the state of packaging. This page,
|
||||
in turn, will list any known modules or plugins that are known to misbehave, and
|
||||
possible workarounds that you may apply.</p>
|
||||
<h2 id="ch-quirks-nodejs">NodeJS</h2>
|
||||
<h3 id="sec-eslint-plugin-prettier">eslint-plugin-prettier</h3>
|
||||
<p>When working with NodeJS, everything works as expected, but some projects have
|
||||
settings that can fool nvf.</p>
|
||||
<p>If <a href="https://github.com/prettier/eslint-plugin-prettier">this plugin</a> or similar
|
||||
is included, you might get a situation where your eslint configuration diagnoses
|
||||
your formatting according to its own config (usually <code>.eslintrc.js</code>).</p>
|
||||
<p>The issue there is your formatting is made via prettierd.</p>
|
||||
<p>This results in auto-formatting relying on your prettier config, while your
|
||||
eslint config diagnoses formatting
|
||||
<a href="https://prettier.io/docs/en/comparison.html">which it's not supposed to</a>)</p>
|
||||
<p>In the end, you get discrepancies between what your editor does and what it
|
||||
wants.</p>
|
||||
<p>Solutions are:</p>
|
||||
<ol>
|
||||
<li>Don't add a formatting config to eslint, and separate prettier and eslint.</li>
|
||||
<li>PR this repo to add an ESLint formatter and configure nvf to use it.</li>
|
||||
</ol>
|
||||
<h2 id="ch-bugs-suggestions">Bugs & Suggestions</h2>
|
||||
<p>Some quirks are not exactly quirks, but bugs in the module systeme. If you
|
||||
notice any issues with nvf, or this documentation, then please consider
|
||||
reporting them over at the <a href="https://github.com/notashelf/nvf/issues">issue tracker</a>. Issues tab, in addition to the
|
||||
<a href="https://github.com/notashelf/nvf/discussions">discussions tab</a> is a good place as any to request new features.</p>
|
||||
<p>You may also consider submitting bugfixes, feature additions and upstreamed
|
||||
changes that you think are critical over at the <a href="https://github.com/notashelf/nvf/pulls">pull requests tab</a>.</p>
|
||||
</body></html></main>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Generated with ndg</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,106 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NVF - Search</title>
|
||||
|
||||
<script>
|
||||
// Apply sidebar state immediately to prevent flash
|
||||
(function () {
|
||||
if (localStorage.getItem("sidebar-collapsed") === "true") {
|
||||
document.documentElement.classList.add("sidebar-collapsed");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="assets/style.css" />
|
||||
<script defer src="assets/main.js"></script>
|
||||
<script defer src="assets/search.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<h1 class="site-title">
|
||||
<a href="index.html">NVF</a>
|
||||
</h1>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<ul>
|
||||
<li >
|
||||
<a href="options.html">Options</a>
|
||||
</li>
|
||||
|
||||
<li><a href="search.html">Search</a></li>
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-input" placeholder="Search..." />
|
||||
<div id="search-results" class="search-results"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<div class="sidebar-toggle" aria-label="Toggle sidebar">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<nav id="sidebar" class="sidebar">
|
||||
<div class="docs-nav">
|
||||
<h2>Documents</h2>
|
||||
<ul>
|
||||
<li><a href="index.html">Introduction</a></li>
|
||||
<li><a href="configuring.html">Configuring nvf</a></li>
|
||||
<li><a href="hacking.html">Hacking nvf</a></li>
|
||||
<li><a href="tips.html">Helpful Tips</a></li>
|
||||
<li><a href="quirks.html">Known Issues and Quirks</a></li>
|
||||
<li><a href="release-notes.html">Release Notes</a></li>
|
||||
<li><a href="search.html">Search</a></li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="toc">
|
||||
<h2>Contents</h2>
|
||||
<ul class="toc-list">
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="content">
|
||||
<h1>Search</h1>
|
||||
<div class="search-page">
|
||||
<div class="search-form">
|
||||
<input
|
||||
type="text"
|
||||
id="search-page-input"
|
||||
placeholder="Search..."
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div id="search-page-results" class="search-page-results"></div>
|
||||
</div>
|
||||
<div class="footnotes-container">
|
||||
<!-- Footnotes will be appended here -->
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Generated with ndg</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,622 +0,0 @@
|
|||
// 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">×</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
|
|
@ -1,64 +0,0 @@
|
|||
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);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,786 +0,0 @@
|
|||
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
|
|
@ -1,133 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Known Issues and Quirks</title>
|
||||
|
||||
<script>
|
||||
// Apply sidebar state immediately to prevent flash
|
||||
(function () {
|
||||
if (localStorage.getItem("sidebar-collapsed") === "true") {
|
||||
document.documentElement.classList.add("sidebar-collapsed");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="assets/style.css" />
|
||||
<script defer src="assets/main.js"></script>
|
||||
|
||||
<script>
|
||||
window.searchNamespace = window.searchNamespace || {};
|
||||
window.searchNamespace.rootPath = "";
|
||||
</script>
|
||||
<script defer src="assets/search.js"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<h1 class="site-title">
|
||||
<a href="index.html">NVF</a>
|
||||
</h1>
|
||||
|
||||
<nav class="header-nav">
|
||||
<ul>
|
||||
<li >
|
||||
<a href="options.html">Options</a>
|
||||
</li>
|
||||
|
||||
<li><a href="search.html">Search</a></li>
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-input" placeholder="Search..." />
|
||||
<div id="search-results" class="search-results"></div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<div class="sidebar-toggle" aria-label="Toggle sidebar">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<nav class="sidebar">
|
||||
<div class="docs-nav">
|
||||
<h2>Documents</h2>
|
||||
<ul>
|
||||
<li><a href="index.html">Introduction</a></li>
|
||||
<li><a href="configuring.html">Configuring nvf</a></li>
|
||||
<li><a href="hacking.html">Hacking nvf</a></li>
|
||||
<li><a href="tips.html">Helpful Tips</a></li>
|
||||
<li><a href="quirks.html">Known Issues and Quirks</a></li>
|
||||
<li><a href="release-notes.html">Release Notes</a></li>
|
||||
<li><a href="search.html">Search</a></li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="toc">
|
||||
<h2>Contents</h2>
|
||||
<ul class="toc-list">
|
||||
<li><a href="#ch-known-issues-quirks">Known Issues and Quirks</a>
|
||||
<ul><li><a href="#ch-quirks-nodejs">NodeJS</a>
|
||||
<ul><li><a href="#sec-eslint-plugin-prettier">eslint-plugin-prettier</a>
|
||||
</ul><li><a href="#ch-bugs-suggestions">Bugs & Suggestions</a>
|
||||
</li></ul></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="content"><html><head></head><body><h1 id="ch-known-issues-quirks">Known Issues and Quirks</h1>
|
||||
<p>At times, certain plugins and modules may refuse to play nicely with your setup,
|
||||
be it a result of generating Lua from Nix, or the state of packaging. This page,
|
||||
in turn, will list any known modules or plugins that are known to misbehave, and
|
||||
possible workarounds that you may apply.</p>
|
||||
<h2 id="ch-quirks-nodejs">NodeJS</h2>
|
||||
<h3 id="sec-eslint-plugin-prettier">eslint-plugin-prettier</h3>
|
||||
<p>When working with NodeJS, everything works as expected, but some projects have
|
||||
settings that can fool nvf.</p>
|
||||
<p>If <a href="https://github.com/prettier/eslint-plugin-prettier">this plugin</a> or similar
|
||||
is included, you might get a situation where your eslint configuration diagnoses
|
||||
your formatting according to its own config (usually <code>.eslintrc.js</code>).</p>
|
||||
<p>The issue there is your formatting is made via prettierd.</p>
|
||||
<p>This results in auto-formatting relying on your prettier config, while your
|
||||
eslint config diagnoses formatting
|
||||
<a href="https://prettier.io/docs/en/comparison.html">which it's not supposed to</a>)</p>
|
||||
<p>In the end, you get discrepancies between what your editor does and what it
|
||||
wants.</p>
|
||||
<p>Solutions are:</p>
|
||||
<ol>
|
||||
<li>Don't add a formatting config to eslint, and separate prettier and eslint.</li>
|
||||
<li>PR this repo to add an ESLint formatter and configure nvf to use it.</li>
|
||||
</ol>
|
||||
<h2 id="ch-bugs-suggestions">Bugs & Suggestions</h2>
|
||||
<p>Some quirks are not exactly quirks, but bugs in the module systeme. If you
|
||||
notice any issues with nvf, or this documentation, then please consider
|
||||
reporting them over at the <a href="https://github.com/notashelf/nvf/issues">issue tracker</a>. Issues tab, in addition to the
|
||||
<a href="https://github.com/notashelf/nvf/discussions">discussions tab</a> is a good place as any to request new features.</p>
|
||||
<p>You may also consider submitting bugfixes, feature additions and upstreamed
|
||||
changes that you think are critical over at the <a href="https://github.com/notashelf/nvf/pulls">pull requests tab</a>.</p>
|
||||
</body></html></main>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Generated with ndg</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,106 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NVF - Search</title>
|
||||
|
||||
<script>
|
||||
// Apply sidebar state immediately to prevent flash
|
||||
(function () {
|
||||
if (localStorage.getItem("sidebar-collapsed") === "true") {
|
||||
document.documentElement.classList.add("sidebar-collapsed");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="assets/style.css" />
|
||||
<script defer src="assets/main.js"></script>
|
||||
<script defer src="assets/search.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<h1 class="site-title">
|
||||
<a href="index.html">NVF</a>
|
||||
</h1>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<ul>
|
||||
<li >
|
||||
<a href="options.html">Options</a>
|
||||
</li>
|
||||
|
||||
<li><a href="search.html">Search</a></li>
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-input" placeholder="Search..." />
|
||||
<div id="search-results" class="search-results"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<div class="sidebar-toggle" aria-label="Toggle sidebar">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<nav id="sidebar" class="sidebar">
|
||||
<div class="docs-nav">
|
||||
<h2>Documents</h2>
|
||||
<ul>
|
||||
<li><a href="index.html">Introduction</a></li>
|
||||
<li><a href="configuring.html">Configuring nvf</a></li>
|
||||
<li><a href="hacking.html">Hacking nvf</a></li>
|
||||
<li><a href="tips.html">Helpful Tips</a></li>
|
||||
<li><a href="quirks.html">Known Issues and Quirks</a></li>
|
||||
<li><a href="release-notes.html">Release Notes</a></li>
|
||||
<li><a href="search.html">Search</a></li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="toc">
|
||||
<h2>Contents</h2>
|
||||
<ul class="toc-list">
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="content">
|
||||
<h1>Search</h1>
|
||||
<div class="search-page">
|
||||
<div class="search-form">
|
||||
<input
|
||||
type="text"
|
||||
id="search-page-input"
|
||||
placeholder="Search..."
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div id="search-page-results" class="search-page-results"></div>
|
||||
</div>
|
||||
<div class="footnotes-container">
|
||||
<!-- Footnotes will be appended here -->
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Generated with ndg</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
27
options.html
27
options.html
|
|
@ -8359,12 +8359,20 @@
|
|||
<details class="toc-category">
|
||||
<summary title="vim.languages.rust">
|
||||
<span>vim.languages.rust</span>
|
||||
<span class="toc-count">19</span>
|
||||
<span class="toc-count">20</span>
|
||||
</summary>
|
||||
<ul>
|
||||
|
||||
|
||||
|
||||
<li>
|
||||
<a href='#option-vim-languages-rust-dap-adapter' title="vim.languages.rust.dap.adapter">
|
||||
dap.adapter
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href='#option-vim-languages-rust-dap-enable' title="vim.languages.rust.dap.enable">
|
||||
dap.enable
|
||||
|
|
@ -31519,6 +31527,23 @@ This is a python package with debugpy installed, see <a href="https://nixos.wiki
|
|||
<div class="option-default">Default: <code>pkgs.vimPlugins.nvim-treesitter.builtGrammars.ruby</code></div>
|
||||
<div class="option-declared">Declared in: <code><a href="https://github.com/NotAShelf/nvf/blob/main/modules/plugins/languages/ruby.nix" target="_blank"><nvf/modules/plugins/languages/ruby.nix></a></code></div>
|
||||
</div>
|
||||
<div class="option" id="option-vim-languages-rust-dap-adapter">
|
||||
<h3 class="option-name">
|
||||
<a href="#option-vim-languages-rust-dap-adapter" class="option-anchor">vim.languages.rust.dap.adapter</a>
|
||||
<span class="copy-link" title="Copy link to this option"></span>
|
||||
<span class="copy-feedback">Link copied!</span>
|
||||
</h3>
|
||||
<div class="option-type">Type: <code>one of "lldb-dap", "codelldb"</code></div>
|
||||
<div class="option-description"><html><head></head><body><p>Select which LLDB-based debug adapter to use:</p>
|
||||
<ul>
|
||||
<li>"codelldb": use the CodeLLDB adapter from the vadimcn.vscode-lldb extension.</li>
|
||||
<li>"lldb-dap": use the LLDB DAP implementation shipped with LLVM (lldb-dap).</li>
|
||||
</ul>
|
||||
<p>The default "codelldb" backend generally provides a better debugging experience for Rust.</p>
|
||||
</body></html></div>
|
||||
<div class="option-default">Default: <code>"codelldb"</code></div>
|
||||
<div class="option-declared">Declared in: <code><a href="https://github.com/NotAShelf/nvf/blob/main/modules/plugins/languages/rust.nix" target="_blank"><nvf/modules/plugins/languages/rust.nix></a></code></div>
|
||||
</div>
|
||||
<div class="option" id="option-vim-languages-rust-dap-enable">
|
||||
<h3 class="option-name">
|
||||
<a href="#option-vim-languages-rust-dap-enable" class="option-anchor">vim.languages.rust.dap.enable</a>
|
||||
|
|
|
|||
|
|
@ -1813,6 +1813,12 @@ attach behavior.</li>
|
|||
<ul>
|
||||
<li>Added gitFiles mapping option to telescope</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/Ring-A-Ding-Ding-Baby">Ring-A-Ding-Ding-Baby</a></p>
|
||||
<ul>
|
||||
<li>Aligned <code>codelldb</code> adapter setup with <a href="https://github.com/mrcjkb/rustaceanvim">rustaceanvim</a>’s built-in logic.</li>
|
||||
<li>Added <code>languages.rust.dap.backend</code> option to choose between <code>codelldb</code> and
|
||||
<code>lldb-dap</code> adapters.</li>
|
||||
</ul>
|
||||
</body></html></main>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue