refactor: rewrite search component with fastsearch
This commit is contained in:
parent
a7fea79d98
commit
45e43928e7
3 changed files with 188 additions and 81 deletions
|
@ -18,7 +18,6 @@
|
||||||
"@astrojs/sitemap": "^3.3.1",
|
"@astrojs/sitemap": "^3.3.1",
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.2.5",
|
"@fontsource-variable/jetbrains-mono": "^5.2.5",
|
||||||
"astro": "^5.2.5",
|
"astro": "^5.2.5",
|
||||||
"fuse.js": "^7.0.0",
|
|
||||||
"sharp": "^0.34.1",
|
"sharp": "^0.34.1",
|
||||||
"ultrahtml": "^1.6.0"
|
"ultrahtml": "^1.6.0"
|
||||||
},
|
},
|
||||||
|
|
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
|
@ -29,9 +29,6 @@ importers:
|
||||||
astro:
|
astro:
|
||||||
specifier: ^5.2.5
|
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)
|
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:
|
sharp:
|
||||||
specifier: ^0.34.1
|
specifier: ^0.34.1
|
||||||
version: 0.34.1
|
version: 0.34.1
|
||||||
|
@ -1692,10 +1689,6 @@ packages:
|
||||||
function-bind@1.1.2:
|
function-bind@1.1.2:
|
||||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
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:
|
get-caller-file@2.0.5:
|
||||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||||
engines: {node: 6.* || 8.* || >= 10.*}
|
engines: {node: 6.* || 8.* || >= 10.*}
|
||||||
|
@ -4927,8 +4920,6 @@ snapshots:
|
||||||
|
|
||||||
function-bind@1.1.2: {}
|
function-bind@1.1.2: {}
|
||||||
|
|
||||||
fuse.js@7.1.0: {}
|
|
||||||
|
|
||||||
get-caller-file@2.0.5: {}
|
get-caller-file@2.0.5: {}
|
||||||
|
|
||||||
get-east-asian-width@1.3.0: {}
|
get-east-asian-width@1.3.0: {}
|
||||||
|
|
|
@ -25,85 +25,202 @@ const noscript = siteConfig.noClientJavaScript
|
||||||
</div>
|
</div>
|
||||||
<div id="search-results" class="search-results"></div>
|
<div id="search-results" class="search-results"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Fuse from 'fuse.js';
|
/*
|
||||||
|
====================================================================
|
||||||
|
|
||||||
let fuse;
|
tweaked from FAST SEARCH —
|
||||||
let posts = [];
|
https://gist.github.com/cmod/5410eae147e4318164258742dd053993
|
||||||
let searchInitialized = false;
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
// Fetch the search index
|
|
||||||
const response = await fetch('/search-index.json');
|
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, {
|
searchIndex = data.map((item: any) => ({
|
||||||
keys: ['title', 'description', 'content'],
|
...item,
|
||||||
threshold: 0.3,
|
searchableTitle: item.title?.toLowerCase() || '',
|
||||||
includeMatches: true
|
searchableDesc: item.description?.toLowerCase() || '',
|
||||||
});
|
searchableContent: (item.content as string)?.toLowerCase() || ''
|
||||||
searchInitialized = true;
|
}));
|
||||||
document.getElementById('search-results').innerHTML = '';
|
|
||||||
|
if (searchInput && searchInput.value) {
|
||||||
|
performSearch(searchInput.value);
|
||||||
|
}
|
||||||
} catch (error) {
|
} 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) {
|
// Simple fuzzy match for single words
|
||||||
if (!query) {
|
function simpleFuzzyMatch(text: string, term: string): boolean {
|
||||||
document.getElementById('search-results').innerHTML = '';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!searchInitialized) return;
|
if (term.length < CONFIG.search.minChars) {
|
||||||
|
searchResults.innerHTML = '<span class="search-message">Please enter at least 2 characters...</span>';
|
||||||
const results = fuse.search(query);
|
resultsAvailable = false;
|
||||||
const resultsElement = document.getElementById('search-results');
|
|
||||||
|
|
||||||
if (results.length === 0) {
|
|
||||||
resultsElement.innerHTML = '<p>No results found.</p>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = results
|
// Split search into terms
|
||||||
.map(result => `
|
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">
|
<div class="search-result">
|
||||||
<a href="/post/${result.item.slug}">
|
<a href="/post/${item.slug}" tabindex="0">
|
||||||
<span class="result-title">${result.item.title}</span>
|
<span class="result-title">${item.title}</span><br />
|
||||||
<span class="result-date">${new Date(result.item.pubDate).toISOString().split('T')[0]}</span>
|
<span class="result-date">${new Date(item.pubDate).toISOString().split('T')[0]|| '' } —
|
||||||
|
<em>${item.description || ''}</em></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
// Add event listener for search input
|
// Add event listener for search input
|
||||||
const searchInput = document.getElementById('search-input');
|
if (searchInput) {
|
||||||
|
// Load index on focus
|
||||||
// Initialize search only when the input is focused or clicked
|
searchInput.addEventListener('focus', function() {
|
||||||
searchInput.addEventListener('focus', initializeSearch);
|
loadSearchIndex();
|
||||||
searchInput.addEventListener('click', initializeSearch);
|
|
||||||
|
|
||||||
searchInput.addEventListener('input', (e) => {
|
|
||||||
if (!searchInitialized) {
|
|
||||||
initializeSearch().then(() => {
|
|
||||||
performSearch(e.target.value);
|
|
||||||
});
|
});
|
||||||
} 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>}
|
</script>}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue