This commit is contained in:
NotAShelf 2026-01-22 19:55:55 +00:00
commit 3435dce176
13 changed files with 4554 additions and 5571 deletions

View file

@ -1,8 +1,8 @@
// Polyfill for requestIdleCallback for Safari and unsupported browsers
if (typeof window.requestIdleCallback === "undefined") {
window.requestIdleCallback = function (cb) {
var start = Date.now();
var idlePeriod = 50;
const start = Date.now();
const idlePeriod = 50;
return setTimeout(function () {
cb({
didTimeout: false,
@ -85,6 +85,127 @@ function createMobileElements() {
}
}
// Initialize collapsible sidebar sections with state persistence
function initCollapsibleSections() {
// Target sections in both desktop and mobile sidebars
const sections = document.querySelectorAll(
".sidebar .sidebar-section, .mobile-sidebar-content .sidebar-section",
);
sections.forEach((section) => {
const sectionId = section.dataset.section;
if (!sectionId) return;
const storageKey = `sidebar-section-${sectionId}`;
const savedState = localStorage.getItem(storageKey);
// Restore saved state (default is open)
if (savedState === "closed") {
section.removeAttribute("open");
}
// Save state on toggle and sync between desktop/mobile
section.addEventListener("toggle", () => {
localStorage.setItem(storageKey, section.open ? "open" : "closed");
// Sync state between desktop and mobile versions
const allWithSameSection = document.querySelectorAll(
`.sidebar-section[data-section="${sectionId}"]`,
);
allWithSameSection.forEach((el) => {
if (el !== section) {
el.open = section.open;
}
});
});
});
}
// Initialize scroll spy
function initScrollSpy() {
const pageToc = document.querySelector(".page-toc");
if (!pageToc) return;
const tocLinks = pageToc.querySelectorAll(".page-toc-list a");
const content = document.querySelector(".content");
if (!tocLinks.length || !content) return;
const headings = Array.from(
content.querySelectorAll("h1[id], h2[id], h3[id]"),
);
if (!headings.length) return;
// Build a map of heading IDs to TOC links for quick lookup
const linkMap = new Map();
tocLinks.forEach((link) => {
const href = link.getAttribute("href");
if (href && href.startsWith("#")) {
linkMap.set(href.slice(1), link);
}
});
let activeLink = null;
// Update active link based on scroll position
function updateActiveLink() {
const threshold = 120; // threshold from the top of the viewport
let currentHeading = null;
// Find the last heading that is at or above the threshold
for (const heading of headings) {
const rect = heading.getBoundingClientRect();
if (rect.top <= threshold) {
currentHeading = heading;
}
}
// If no heading is above threshold, use first heading if it's in view
if (!currentHeading && headings.length > 0) {
const firstRect = headings[0].getBoundingClientRect();
if (firstRect.top < window.innerHeight) {
currentHeading = headings[0];
}
}
const newLink = currentHeading ? linkMap.get(currentHeading.id) : null;
if (newLink !== activeLink) {
if (activeLink) {
activeLink.classList.remove("active");
}
if (newLink) {
newLink.classList.add("active");
}
activeLink = newLink;
}
}
// Scroll event handler
let ticking = false;
function onScroll() {
if (!ticking) {
requestAnimationFrame(() => {
updateActiveLink();
ticking = false;
});
ticking = true;
}
}
window.addEventListener("scroll", onScroll, { passive: true });
// Also update on hash change (direct link navigation)
window.addEventListener("hashchange", () => {
requestAnimationFrame(updateActiveLink);
});
// Set initial active state after a small delay to ensure
// browser has completed any hash-based scrolling
setTimeout(updateActiveLink, 100);
}
document.addEventListener("DOMContentLoaded", function () {
// Apply sidebar state immediately before DOM rendering
if (localStorage.getItem("sidebar-collapsed") === "true") {
@ -96,6 +217,13 @@ document.addEventListener("DOMContentLoaded", function () {
createMobileElements();
}
// Initialize collapsible sidebar sections
// after mobile elements are created
initCollapsibleSections();
// Initialize scroll spy for page TOC
initScrollSpy();
// Desktop Sidebar Toggle
const sidebarToggle = document.querySelector(".sidebar-toggle");
@ -111,8 +239,9 @@ document.addEventListener("DOMContentLoaded", function () {
document.body.classList.toggle("sidebar-collapsed");
// Use documentElement to check state and save to localStorage
const isCollapsed =
document.documentElement.classList.contains("sidebar-collapsed");
const isCollapsed = document.documentElement.classList.contains(
"sidebar-collapsed",
);
localStorage.setItem("sidebar-collapsed", isCollapsed);
});
}
@ -144,7 +273,7 @@ document.addEventListener("DOMContentLoaded", function () {
}
// Make the entire heading clickable
heading.addEventListener("click", function (e) {
heading.addEventListener("click", function () {
const id = this.id;
history.pushState(null, null, "#" + id);
@ -257,19 +386,10 @@ document.addEventListener("DOMContentLoaded", function () {
".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");
@ -379,8 +499,6 @@ document.addEventListener("DOMContentLoaded", function () {
});
}
// Options filter functionality
const optionsFilter = document.getElementById("options-filter");
if (optionsFilter) {
@ -404,8 +522,8 @@ document.addEventListener("DOMContentLoaded", function () {
);
// Detect if we're on a mobile device
const isMobile =
window.innerWidth < 768 || /Mobi|Android/i.test(navigator.userAgent);
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"));
@ -483,7 +601,8 @@ document.addEventListener("DOMContentLoaded", function () {
// 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.textContent =
`Showing ${filterResults.visibleCount} of ${totalCount} options`;
filterResults.style.display = "block";
} else {
filterResults.style.display = "none";
@ -530,8 +649,7 @@ document.addEventListener("DOMContentLoaded", function () {
isDescMatch = !isTitleMatch && data.description.includes(term);
} else {
isTitleMatch = searchTerms.every((term) => data.name.includes(term));
isDescMatch =
!isTitleMatch &&
isDescMatch = !isTitleMatch &&
searchTerms.every((term) => data.description.includes(term));
}
if (isTitleMatch) {

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,151 @@
self.onmessage = function(e) {
const isWordBoundary = (char) =>
/[A-Z]/.test(char) || /[-_\/.]/.test(char) || /\s/.test(char);
const isCaseTransition = (prev, curr) => {
const prevIsUpper = prev.toLowerCase() !== prev;
const currIsUpper = curr.toLowerCase() !== curr;
return (
prevIsUpper && currIsUpper && prev.toLowerCase() !== curr.toLowerCase()
);
};
const findBestSubsequenceMatch = (query, target) => {
const n = query.length;
const m = target.length;
if (n === 0 || m === 0) return null;
const positions = [];
const memo = new Map();
const key = (qIdx, tIdx, gap) => `${qIdx}:${tIdx}:${gap}`;
const findBest = (qIdx, tIdx, currentGap) => {
if (qIdx === n) {
return { done: true, positions: [...positions], gap: currentGap };
}
const memoKey = key(qIdx, tIdx, currentGap);
if (memo.has(memoKey)) {
return memo.get(memoKey);
}
let bestResult = null;
for (let i = tIdx; i < m; i++) {
if (target[i] === query[qIdx]) {
positions.push(i);
const gap = qIdx === 0 ? 0 : i - positions[positions.length - 2] - 1;
const newGap = currentGap + gap;
if (newGap > m) {
positions.pop();
continue;
}
const result = findBest(qIdx + 1, i + 1, newGap);
positions.pop();
if (result && (!bestResult || result.gap < bestResult.gap)) {
bestResult = result;
if (result.gap === 0) break;
}
}
}
memo.set(memoKey, bestResult);
return bestResult;
};
const result = findBest(0, 0, 0);
if (!result) return null;
const consecutive = (() => {
let c = 1;
for (let i = 1; i < result.positions.length; i++) {
if (result.positions[i] === result.positions[i - 1] + 1) {
c++;
}
}
return c;
})();
return {
positions: result.positions,
consecutive,
score: calculateMatchScore(query, target, result.positions, consecutive),
};
};
const calculateMatchScore = (query, target, positions, consecutive) => {
const n = positions.length;
const m = target.length;
if (n === 0) return 0;
let score = 1.0;
const startBonus = (m - positions[0]) / m;
score += startBonus * 0.5;
let gapPenalty = 0;
for (let i = 1; i < n; i++) {
const gap = positions[i] - positions[i - 1] - 1;
if (gap > 0) {
gapPenalty += Math.min(gap / m, 1.0) * 0.3;
}
}
score -= gapPenalty;
const consecutiveBonus = consecutive / n;
score += consecutiveBonus * 0.3;
let boundaryBonus = 0;
for (let i = 0; i < n; i++) {
const char = target[positions[i]];
if (i === 0 || isWordBoundary(char)) {
boundaryBonus += 0.05;
}
if (i > 0) {
const prevChar = target[positions[i - 1]];
if (isCaseTransition(prevChar, char)) {
boundaryBonus += 0.03;
}
}
}
score = Math.min(1.0, score + boundaryBonus);
const lengthPenalty = Math.abs(query.length - n) / Math.max(query.length, m);
score -= lengthPenalty * 0.2;
return Math.max(0, Math.min(1.0, score));
};
const fuzzyMatch = (query, target) => {
const lowerQuery = query.toLowerCase();
const lowerTarget = target.toLowerCase();
if (lowerQuery.length === 0) return null;
if (lowerTarget.length === 0) return null;
if (lowerTarget === lowerQuery) {
return 1.0;
}
if (lowerTarget.includes(lowerQuery)) {
const ratio = lowerQuery.length / lowerTarget.length;
return 0.8 + ratio * 0.2;
}
const match = findBestSubsequenceMatch(lowerQuery, lowerTarget);
if (!match) {
return null;
}
return Math.min(1.0, match.score);
};
self.onmessage = function (e) {
const { messageId, type, data } = e.data;
const respond = (type, data) => {
@ -6,59 +153,146 @@ self.onmessage = function(e) {
};
const respondError = (error) => {
self.postMessage({ messageId, type: 'error', error: error.message || String(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);
if (type === "tokenize") {
const text = typeof data === "string" ? data : "";
const words = text.toLowerCase().match(/\b[a-zA-Z0-9_-]+\b/g) || [];
const tokens = words.filter((word) => word.length > 2);
const uniqueTokens = Array.from(new Set(tokens));
respond('tokens', uniqueTokens);
}
respond("tokens", uniqueTokens);
} else if (type === "search") {
const { query, limit = 10 } = data;
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);
if (!query || typeof query !== "string") {
respond("results", []);
return;
}
const docScores = new Map();
// Pre-compute lower-case terms once
const lowerSearchTerms = searchTerms.map(term => term.toLowerCase());
const rawQuery = query.toLowerCase();
const text = typeof query === "string" ? query : "";
const words = text.toLowerCase().match(/\b[a-zA-Z0-9_-]+\b/g) || [];
const searchTerms = words.filter((word) => word.length > 2);
let documents = [];
if (typeof data.documents === "string") {
documents = JSON.parse(data.documents);
} else if (Array.isArray(data.documents)) {
documents = data.documents;
} else if (typeof data.transferables === "string") {
documents = JSON.parse(data.transferables);
}
if (!Array.isArray(documents) || documents.length === 0) {
respond("results", []);
return;
}
const useFuzzySearch = rawQuery.length >= 3;
if (searchTerms.length === 0 && rawQuery.length < 3) {
respond("results", []);
return;
}
const pageMatches = new Map();
// 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()
}));
const processedDocs = documents.map((doc, docId) => {
const title = typeof doc.title === "string" ? doc.title : "";
const content = typeof doc.content === "string" ? doc.content : "";
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);
return {
docId,
doc,
lowerTitle: title.toLowerCase(),
lowerContent: content.toLowerCase(),
};
});
// First pass: Score pages with fuzzy matching
processedDocs.forEach(({ docId, doc, lowerTitle, lowerContent }) => {
let match = pageMatches.get(docId);
if (!match) {
match = { doc, pageScore: 0, matchingAnchors: [] };
pageMatches.set(docId, match);
}
if (useFuzzySearch) {
const fuzzyTitleScore = fuzzyMatch(rawQuery, lowerTitle);
if (fuzzyTitleScore !== null) {
match.pageScore += fuzzyTitleScore * 100;
}
const fuzzyContentScore = fuzzyMatch(rawQuery, lowerContent);
if (fuzzyContentScore !== null) {
match.pageScore += fuzzyContentScore * 30;
}
}
// Token-based exact matching
searchTerms.forEach((term) => {
if (lowerTitle.includes(term)) {
match.pageScore += lowerTitle === term ? 20 : 10;
}
if (lowerContent.includes(term)) {
match.pageScore += 2;
}
});
});
const results = Array.from(docScores.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([docId, score]) => ({ ...documents[docId], score }));
// Second pass: Find matching anchors
pageMatches.forEach((match) => {
const doc = match.doc;
if (
!doc.anchors ||
!Array.isArray(doc.anchors) ||
doc.anchors.length === 0
) {
return;
}
respond('results', results);
doc.anchors.forEach((anchor) => {
if (!anchor || !anchor.text) return;
const anchorText = anchor.text.toLowerCase();
let anchorMatches = false;
if (useFuzzySearch) {
const fuzzyScore = fuzzyMatch(rawQuery, anchorText);
if (fuzzyScore !== null && fuzzyScore >= 0.4) {
anchorMatches = true;
}
}
if (!anchorMatches) {
searchTerms.forEach((term) => {
if (anchorText.includes(term)) {
anchorMatches = true;
}
});
}
if (anchorMatches) {
match.matchingAnchors.push(anchor);
}
});
});
const results = Array.from(pageMatches.values())
.filter((m) => m.pageScore > 5)
.sort((a, b) => b.pageScore - a.pageScore)
.slice(0, limit);
respond("results", results);
}
} catch (error) {
respondError(error);
}
};
};

File diff suppressed because it is too large Load diff

View file

@ -203,6 +203,11 @@
U+FFFD;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Core Layout */
body {
font-family:
@ -771,7 +776,9 @@ img {
color: rgba(255, 255, 255, 0.7);
}
#search-input:focus {
/* Expand input when focused OR when results are visible */
.search-container:focus-within #search-input,
.search-container.has-results #search-input {
outline: none;
background-color: rgba(255, 255, 255, 0.15);
width: 300px;
@ -1925,7 +1932,6 @@ h6:hover .copy-link {
.mobile-sidebar-container {
display: flex;
/* Use flex for container */
}
.content {
@ -1943,3 +1949,195 @@ h6:hover .copy-link {
margin-bottom: 0 !important;
padding-bottom: 0 !important;
}
/* Search result styles */
.search-result-page {
font-weight: 600;
}
.search-result-anchor {
padding-left: calc(var(--space-4) + var(--space-6));
font-size: 0.9em;
position: relative;
}
.search-result-anchor::before {
content: "↳";
position: absolute;
left: var(--space-4);
color: var(--text-muted);
font-size: 1em;
}
.search-result-anchor a {
color: var(--text-muted);
}
.search-result-anchor:hover::before {
color: var(--link-color);
}
/* Search page specific styling */
.search-page-results .search-result-page {
font-weight: 700;
}
.search-page-results .search-result-anchor {
padding-left: calc(var(--space-6) + var(--space-6));
}
.search-page-results .search-result-anchor::before {
left: var(--space-6);
}
/* Mobile search anchor styling */
.mobile-search-results .search-result-anchor {
padding-left: calc(var(--space-4) + var(--space-4));
font-size: 0.875em;
}
.mobile-search-results .search-result-anchor::before {
left: var(--space-3);
}
/* Sidebar Sections */
.sidebar-section {
margin-bottom: var(--space-2);
}
.sidebar-section summary {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
font-weight: 600;
cursor: pointer;
padding: var(--space-2) var(--space-3);
list-style: none;
display: flex;
align-items: center;
gap: var(--space-2);
border-radius: var(--radius-md);
transition:
color var(--transition-fast),
background-color var(--transition-fast);
}
.sidebar-section summary:hover {
background-color: var(--sidebar-hover);
color: var(--text-color);
}
.sidebar-section summary::before {
content: "▶";
font-size: 0.625rem;
transition: transform var(--transition-fast);
flex-shrink: 0;
}
.sidebar-section[open] summary::before {
transform: rotate(90deg);
}
/* Hide default marker */
.sidebar-section summary::-webkit-details-marker {
display: none;
}
/* Animated content wrapper using CSS Grid */
.sidebar-section-content {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows var(--transition);
}
.sidebar-section[open] .sidebar-section-content {
grid-template-rows: 1fr;
}
.sidebar-section-content > * {
min-height: 0;
overflow: hidden;
}
/* Right-side Page Table of Contents */
.page-toc {
position: fixed;
top: calc(var(--header-height) + var(--space-4));
max-height: calc(100vh - var(--header-height) - var(--space-8));
width: 220px;
overflow-y: auto;
padding: var(--space-4);
font-size: 0.8125rem;
display: none;
/* Position to the right of centered content
Content is max 800px, centered. Sidebar is 300px fixed left.
Calculate: sidebar (300px) + half remaining + half content (400px) + gap
*/
right: var(--space-4);
}
.page-toc-nav h3 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin: 0 0 var(--space-3) 0;
padding-left: var(--space-3);
font-weight: 600;
}
.page-toc-list {
list-style: none;
padding: 0;
margin: 0;
border-left: 2px solid var(--border-color);
}
.page-toc-list ul {
list-style: none;
padding-left: var(--space-3);
margin: 0;
}
.page-toc-list li {
margin: 0;
}
.page-toc-list a {
display: block;
padding: var(--space-1) var(--space-3);
color: var(--text-muted);
text-decoration: none;
transition:
color var(--transition-fast),
border-color var(--transition-fast);
border-left: 2px solid transparent;
margin-left: -2px;
}
.page-toc-list a:hover {
color: var(--link-color);
}
.page-toc-list a.active {
color: var(--link-color);
border-left-color: var(--link-color);
font-weight: 500;
}
/* Show page-toc only on wide screens */
@media (min-width: 1400px) {
.page-toc {
display: block;
}
}
/* Hide page-toc on mobile */
@media (max-width: 800px) {
.page-toc {
display: none !important;
}
}