This commit is contained in:
horriblename 2025-12-13 13:22:16 +00:00
commit 1d46a2cf41
43 changed files with 33 additions and 165801 deletions

View file

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

File diff suppressed because one or more lines are too long

View file

@ -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);
}
};

View file

@ -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

View file

@ -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 &amp; 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 &amp; 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

View file

@ -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