refactor: rewrite search component with fastsearch

This commit is contained in:
grassblock 2025-05-22 22:54:13 +08:00
parent a7fea79d98
commit 45e43928e7
3 changed files with 188 additions and 81 deletions

View file

@ -18,7 +18,6 @@
"@astrojs/sitemap": "^3.3.1",
"@fontsource-variable/jetbrains-mono": "^5.2.5",
"astro": "^5.2.5",
"fuse.js": "^7.0.0",
"sharp": "^0.34.1",
"ultrahtml": "^1.6.0"
},

9
pnpm-lock.yaml generated
View file

@ -29,9 +29,6 @@ importers:
astro:
specifier: ^5.2.5
version: 5.7.10(@azure/identity@4.9.1)(@types/node@22.15.3)(rollup@4.40.1)(typescript@5.8.3)(yaml@2.7.1)
fuse.js:
specifier: ^7.0.0
version: 7.1.0
sharp:
specifier: ^0.34.1
version: 0.34.1
@ -1692,10 +1689,6 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
fuse.js@7.1.0:
resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==}
engines: {node: '>=10'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
@ -4927,8 +4920,6 @@ snapshots:
function-bind@1.1.2: {}
fuse.js@7.1.0: {}
get-caller-file@2.0.5: {}
get-east-asian-width@1.3.0: {}

View file

@ -25,85 +25,202 @@ const noscript = siteConfig.noClientJavaScript
</div>
<div id="search-results" class="search-results"></div>
</div>
<script>
import Fuse from 'fuse.js';
/*
====================================================================
let fuse;
let posts = [];
let searchInitialized = false;
tweaked from FAST SEARCH —
https://gist.github.com/cmod/5410eae147e4318164258742dd053993
async function initializeSearch() {
// Prevent multiple initializations
if (searchInitialized) return;
====================================================================
*/
// Configuration
const CONFIG = {
search: {
minChars: 2, // Minimum characters before searching
maxResults: 5, // Maximum number of results to show
fields: { // Fields to search through
title: true, // Allow searching in title
description: true, // Allow searching in description
content: true // Allow searching in contents
}
}
};
// Function to initialize search with custom config
function initSearch(): void {
// Cache DOM elements
const searchInput = document.getElementById('search-input') as HTMLInputElement;
const searchResults = document.getElementById('search-results') as HTMLElement;
let searchIndex: Array<{
title: string;
description?: string;
slug?: string;
pubDate?: Date;
searchableTitle: string;
searchableDesc: string;
searchableContent: string;
}> | null = null;
let resultsAvailable = false;
let firstRun = true;
// Load the search index
async function loadSearchIndex(): Promise<void> {
try {
// Fetch the search index
const response = await fetch('/search-index.json');
posts = await response.json();
if (!response.ok) throw new Error('Failed to load search index');
const data = await response.json();
fuse = new Fuse(posts, {
keys: ['title', 'description', 'content'],
threshold: 0.3,
includeMatches: true
});
searchInitialized = true;
document.getElementById('search-results').innerHTML = '';
searchIndex = data.map((item: any) => ({
...item,
searchableTitle: item.title?.toLowerCase() || '',
searchableDesc: item.description?.toLowerCase() || '',
searchableContent: (item.content as string)?.toLowerCase() || ''
}));
if (searchInput && searchInput.value) {
performSearch(searchInput.value);
}
} catch (error) {
console.error('Error fetching search index:', error);
console.error('Error loading search index:', error);
if (searchResults) {
searchResults.innerHTML = '<li class="search-message">Error loading search index...</li>';
}
}
}
function performSearch(query) {
if (!query) {
document.getElementById('search-results').innerHTML = '';
// Simple fuzzy match for single words
function simpleFuzzyMatch(text: string, term: string): boolean {
if (text.includes(term)) return true;
if (term.length < 3) return false;
let matches = 0;
let lastMatchIndex = -1;
for (let i = 0; i < term.length; i++) {
const found = text.indexOf(term[i], lastMatchIndex + 1);
if (found > -1) {
matches++;
lastMatchIndex = found;
}
}
return matches === term.length;
}
function performSearch(term: string): void {
term = term.toLowerCase().trim();
if (!term || !searchIndex) {
searchResults.innerHTML = '';
resultsAvailable = false;
return;
}
if (!searchInitialized) return;
const results = fuse.search(query);
const resultsElement = document.getElementById('search-results');
if (results.length === 0) {
resultsElement.innerHTML = '<p>No results found.</p>';
if (term.length < CONFIG.search.minChars) {
searchResults.innerHTML = '<span class="search-message">Please enter at least 2 characters...</span>';
resultsAvailable = false;
return;
}
const html = results
.map(result => `
// Split search into terms
const searchTerms = term.split(/\s+/).filter(t => t.length > 0);
// Search with scoring
const results = searchIndex
.map(item => {
let score = 0;
const matchesAllTerms = searchTerms.every(term => {
let matched = false;
// Title matches (weighted higher)
if (CONFIG.search.fields.title) {
if (item.searchableTitle.startsWith(term)) {
score += 3; // Highest score for prefix matches in title
matched = true;
} else if (simpleFuzzyMatch(item.searchableTitle, term)) {
score += 2; // Good score for fuzzy matches in title
matched = true;
}
}
// Other field matches
if (!matched) {
if (CONFIG.search.fields.description && item.searchableDesc.includes(term)) {
score += 0.5; // Lower score for description matches
matched = true;
}
if (CONFIG.search.fields.content && item.searchableContent.includes(term)) {
score += 0.5; // Lower score for content matches
matched = true;
}
}
return matched;
});
return {
item,
score: matchesAllTerms ? score : 0
};
})
.filter(result => result.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, CONFIG.search.maxResults)
.map(result => result.item);
resultsAvailable = results.length > 0;
if (!resultsAvailable) {
searchResults.innerHTML = '<span class="search-message">No matching results found...</span>';
return;
}
const searchItems = results.map(item => `
<div class="search-result">
<a href="/post/${result.item.slug}">
<span class="result-title">${result.item.title}</span>
<span class="result-date">${new Date(result.item.pubDate).toISOString().split('T')[0]}</span>
<a href="/post/${item.slug}" tabindex="0">
<span class="result-title">${item.title}</span><br />
<span class="result-date">${new Date(item.pubDate).toISOString().split('T')[0]|| '' } —
<em>${item.description || ''}</em></span>
</a>
</div>
`)
.join('');
`).join('');
resultsElement.innerHTML = html;
searchResults.innerHTML = searchItems;
}
// Basic HTML escaping for security
function escapeHtml(unsafe: string): string {
if (!unsafe) return '';
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Add event listener for search input
const searchInput = document.getElementById('search-input');
// Initialize search only when the input is focused or clicked
searchInput.addEventListener('focus', initializeSearch);
searchInput.addEventListener('click', initializeSearch);
searchInput.addEventListener('input', (e) => {
if (!searchInitialized) {
initializeSearch().then(() => {
performSearch(e.target.value);
if (searchInput) {
// Load index on focus
searchInput.addEventListener('focus', function() {
loadSearchIndex();
});
} else {
performSearch(e.target.value);
searchInput.addEventListener('input', function() {
if (!searchIndex && !firstRun) {
searchResults.innerHTML = '<span class="search-message">Loading search index...</span>';
return;
}
performSearch(this.value);
});
searchInput.addEventListener('focus', () => {
if (!searchInitialized) {
document.getElementById('search-results').innerHTML = '<p>Loading search index...</p>';
}
});
firstRun = false;
}
// Initialize with default config
initSearch();
</script>}
<style>