Files
AetherForge/server/web/public/docs/wiki.js
AetherForge a32860b0d9
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
feat: alive UI wave, galaxy presence, spread and fleet enhancements
Dashboard ambient layer, comrade presence, Mission Deck and War Room, Emberwake supply chain, spread/docs publishing, fleet policy and modules API, CI docker mining, and refreshed USB pack.
2026-06-04 22:36:17 -07:00

250 lines
7.8 KiB
JavaScript

(function () {
const navLinks = document.querySelectorAll('.wiki-nav a[href^="#"]');
const sections = Array.from(navLinks).map((link) => {
const id = link.getAttribute('href').slice(1);
return { link, el: document.getElementById(id) };
}).filter((s) => s.el);
function setActive(id) {
navLinks.forEach((a) => {
a.classList.toggle('active', a.getAttribute('href') === '#' + id);
});
}
function scrollToTarget(id, el) {
const target = el || document.getElementById(id);
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
history.replaceState(null, '', '#' + id);
const section =
target.closest('section') || (target.matches && target.matches('section') ? target : null);
if (section) setActive(section.id);
}
navLinks.forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
const id = link.getAttribute('href').slice(1);
scrollToTarget(id);
});
});
if ('IntersectionObserver' in window && sections.length) {
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (visible) setActive(visible.target.id);
},
{ rootMargin: '-20% 0px -60% 0px', threshold: [0, 0.25, 0.5] }
);
sections.forEach((s) => observer.observe(s.el));
}
const hash = window.location.hash.slice(1);
if (hash && document.getElementById(hash)) {
setActive(hash);
const section = document.getElementById(hash).closest('section');
if (section) setActive(section.id);
} else if (sections.length) {
setActive(sections[0].el.id);
}
/* ── Search ── */
const searchInput = document.getElementById('wiki-search-input');
const searchResults = document.getElementById('wiki-search-results');
const HIGHLIGHT_CLASS = 'wiki-search-highlight';
let activeHighlights = [];
function stripText(el) {
return (el.textContent || '').replace(/\s+/g, ' ').trim();
}
function buildSearchIndex() {
const entries = [];
document.querySelectorAll('.wiki-content section').forEach((section) => {
const sectionId = section.id;
const sectionTitle = stripText(section.querySelector('h2') || section);
section.querySelectorAll('h3, h4').forEach((heading) => {
const headingId = heading.id || sectionId;
entries.push({
id: headingId,
sectionId,
title: stripText(heading),
sectionTitle,
text: stripText(heading),
el: heading,
});
});
section.querySelectorAll('p, li, td').forEach((block) => {
const text = stripText(block);
if (text.length < 12) return;
entries.push({
id: sectionId,
sectionId,
title: sectionTitle,
sectionTitle,
text,
el: block,
});
});
});
return entries;
}
const searchIndex = buildSearchIndex();
function clearHighlights() {
activeHighlights.forEach((mark) => {
const parent = mark.parentNode;
if (!parent) return;
parent.replaceChild(document.createTextNode(mark.textContent), mark);
parent.normalize();
});
activeHighlights = [];
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function highlightMatches(el, query) {
clearHighlights();
if (!el || !query) return;
const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 1);
if (!terms.length) return;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
const pattern = new RegExp('(' + terms.map(escapeRegExp).join('|') + ')', 'gi');
textNodes.forEach((node) => {
const val = node.nodeValue;
if (!val || !pattern.test(val)) return;
pattern.lastIndex = 0;
const frag = document.createDocumentFragment();
let last = 0;
val.replace(pattern, (match, _g, offset) => {
if (offset > last) {
frag.appendChild(document.createTextNode(val.slice(last, offset)));
}
const mark = document.createElement('mark');
mark.className = HIGHLIGHT_CLASS;
mark.textContent = match;
frag.appendChild(mark);
activeHighlights.push(mark);
last = offset + match.length;
return match;
});
if (last < val.length) {
frag.appendChild(document.createTextNode(val.slice(last)));
}
node.parentNode.replaceChild(frag, node);
});
}
function scoreEntry(entry, terms) {
const title = entry.title.toLowerCase();
const text = entry.text.toLowerCase();
let score = 0;
terms.forEach((term) => {
if (title.includes(term)) score += 10;
if (text.includes(term)) score += 3;
if (title.startsWith(term)) score += 5;
});
return score;
}
function snippet(text, terms, maxLen) {
const lower = text.toLowerCase();
let idx = -1;
for (const term of terms) {
const i = lower.indexOf(term);
if (i !== -1 && (idx === -1 || i < idx)) idx = i;
}
if (idx === -1) return text.slice(0, maxLen) + (text.length > maxLen ? '…' : '');
const start = Math.max(0, idx - 30);
const slice = text.slice(start, start + maxLen);
return (start > 0 ? '…' : '') + slice + (start + maxLen < text.length ? '…' : '');
}
function renderSearchResults(query) {
if (!searchResults) return;
const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 1);
searchResults.innerHTML = '';
if (!terms.length) {
searchResults.hidden = true;
clearHighlights();
return;
}
const hits = searchIndex
.map((entry) => ({ entry, score: scoreEntry(entry, terms) }))
.filter((h) => h.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 12);
if (!hits.length) {
const li = document.createElement('li');
li.className = 'wiki-search-empty';
li.textContent = 'No matches';
searchResults.appendChild(li);
searchResults.hidden = false;
return;
}
hits.forEach(({ entry }) => {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'wiki-search-hit';
const title = document.createElement('span');
title.className = 'wiki-search-hit-title';
title.textContent = entry.title;
const preview = document.createElement('span');
preview.className = 'wiki-search-hit-preview';
preview.textContent = snippet(entry.text, terms, 80);
btn.appendChild(title);
btn.appendChild(preview);
btn.addEventListener('click', () => {
clearHighlights();
const scrollEl = entry.el.id ? entry.el : document.getElementById(entry.id);
scrollToTarget(entry.id, scrollEl);
const highlightRoot = entry.el.closest('section') || entry.el;
highlightMatches(highlightRoot, query);
searchResults.hidden = true;
searchInput.blur();
});
li.appendChild(btn);
searchResults.appendChild(li);
});
searchResults.hidden = false;
}
if (searchInput && searchResults) {
let debounceTimer;
searchInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => renderSearchResults(searchInput.value.trim()), 120);
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
searchInput.value = '';
searchResults.hidden = true;
clearHighlights();
}
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.wiki-search')) {
searchResults.hidden = true;
}
});
}
})();