const blogIndex = document.querySelector('[data-blog-index]'); if (blogIndex) { const searchInput = blogIndex.querySelector('[data-blog-search]'); const filterDropdown = blogIndex.querySelector('[data-filter-dropdown]'); const filterToggle = blogIndex.querySelector('[data-filter-toggle]'); const filterMenu = blogIndex.querySelector('[data-filter-menu]'); const filterLabel = blogIndex.querySelector('[data-filter-label]'); const filterOptions = Array.from(blogIndex.querySelectorAll('[data-blog-filter]')); const inlineSearchIndex = document.querySelector('[data-blog-search-inline]'); const list = document.querySelector('[data-blog-list]'); const cards = Array.from(document.querySelectorAll('[data-blog-card]')); const loadMore = document.querySelector('[data-blog-load-more]'); const emptyMessage = document.querySelector('[data-blog-empty]'); const pageSize = 4; let activeFilter = ''; let visibleCount = pageSize; let searchIndex = cards.map((card, order) => ({ key: card.dataset.postKey, filters: [], text: card.textContent, order, })); function normalize(value) { return value .toLowerCase() .normalize('NFKD') .replace(/[\u0300-\u036f]/g, ''); } function fuzzyScore(query, text) { const needle = normalize(query.trim()); if (!needle) return 0; const haystack = normalize(text); const exactIndex = haystack.indexOf(needle); if (exactIndex >= 0) return exactIndex; let score = 0; let needleIndex = 0; let lastMatchIndex = -1; for (let haystackIndex = 0; haystackIndex < haystack.length; haystackIndex += 1) { if (haystack[haystackIndex] !== needle[needleIndex]) continue; score += lastMatchIndex >= 0 ? haystackIndex - lastMatchIndex : haystackIndex; lastMatchIndex = haystackIndex; needleIndex += 1; if (needleIndex === needle.length) return score + haystack.length - needle.length; } return Number.POSITIVE_INFINITY; } function wordScore(query, text) { const needle = normalize(query.trim()); const haystack = normalize(text); const words = haystack.split(/[^a-z0-9]+/).filter(Boolean); if (!needle) return 0; if (haystack === needle) return 0; if (haystack.startsWith(needle)) return 1; const exactWordIndex = words.findIndex((word) => word === needle); if (exactWordIndex >= 0) return 3 + exactWordIndex; const prefixWordIndex = words.findIndex((word) => word.startsWith(needle)); if (prefixWordIndex >= 0) return 10 + prefixWordIndex; const exactIndex = haystack.indexOf(needle); if (exactIndex >= 0) return 30 + exactIndex; const subsequenceScore = fuzzyScore(query, text); return subsequenceScore === Number.POSITIVE_INFINITY ? Number.POSITIVE_INFINITY : 100 + subsequenceScore; } function fieldScore(query, value, offset) { const score = wordScore(query, value || ''); return score === Number.POSITIVE_INFINITY ? score : offset + score; } function searchScore(query, entry) { if (!query.trim()) return 0; const fields = entry.fields || { title: entry.title || '', description: entry.description || '', filters: (entry.filters || []).join(' '), body: entry.text || '', }; return Math.min( fieldScore(query, fields.title, 0), fieldScore(query, fields.description, 100), fieldScore(query, fields.filters, 150), fieldScore(query, fields.body, 250), fieldScore(query, entry.text, 400), ); } function updateList(resetVisibleCount = false) { if (resetVisibleCount) visibleCount = pageSize; const query = searchInput.value; const indexedCards = new Map(cards.map((card) => [card.dataset.postKey, card])); const matches = searchIndex .map((entry) => ({ entry, card: indexedCards.get(entry.key), score: searchScore(query, entry), })) .filter(({ entry, card, score }) => ( card && score !== Number.POSITIVE_INFINITY && (!activeFilter || entry.filters.includes(activeFilter)) )) .sort((a, b) => a.score - b.score || a.entry.order - b.entry.order); const visibleMatches = matches.slice(0, visibleCount); cards.forEach((card) => { card.hidden = true; }); visibleMatches.forEach(({ card }) => { card.hidden = false; list.appendChild(card); }); emptyMessage.hidden = matches.length > 0; loadMore.hidden = matches.length <= visibleCount; loadMore.textContent = `Load More${matches.length > visibleCount ? ` (${matches.length - visibleCount})` : ''}`; list.hidden = matches.length === 0; } function setDropdownOpen(isOpen) { filterToggle.setAttribute('aria-expanded', String(isOpen)); filterMenu.hidden = !isOpen; } function selectFilter(option) { activeFilter = option.dataset.blogFilter || ''; filterLabel.textContent = option.textContent; filterOptions.forEach((filterOption) => { const isActive = filterOption === option; filterOption.classList.toggle('is-active', isActive); filterOption.setAttribute('aria-selected', String(isActive)); }); setDropdownOpen(false); updateList(true); } function setSearchIndex(entries) { searchIndex = entries.map((entry, order) => ({ ...entry, order })); updateList(true); } if (inlineSearchIndex) { setSearchIndex(JSON.parse(inlineSearchIndex.textContent)); } else { fetch(blogIndex.dataset.searchIndex) .then((response) => { if (!response.ok) throw new Error(`Unable to load blog search index: ${response.status}`); return response.json(); }) .then(setSearchIndex) .catch((error) => { console.error(error); updateList(true); }); } searchInput.addEventListener('input', () => updateList(true)); filterToggle.addEventListener('click', () => { setDropdownOpen(filterToggle.getAttribute('aria-expanded') !== 'true'); }); filterOptions.forEach((option) => { option.addEventListener('click', () => selectFilter(option)); }); document.addEventListener('click', (event) => { if (!filterDropdown.contains(event.target)) setDropdownOpen(false); }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape') setDropdownOpen(false); }); loadMore.addEventListener('click', () => { visibleCount += pageSize; updateList(); }); updateList(true); }