docs: add Lunr.js search with fuzzy matching and highlighting

Phase 4 part 1: Add search infrastructure
- Add SearchDoc struct and GenerateSearchIndex to docs handler
- Add stripHTML helper for plain text extraction
- Add ServeSearchIndex endpoint to http handler
- Add /docs/search-index.json route in main.go
- Search index includes all documentation files with ID, title, content, URL
This commit is contained in:
2026-02-02 09:08:37 -05:00
parent 3b3630666a
commit 542fbaf116
5 changed files with 406 additions and 60 deletions
+307 -29
View File
@@ -14,6 +14,8 @@ templ DocsLayout(nav Navigation, doc Document, user User) {
<title>{ doc.Title } - Bookhoard Documentation</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lunr@2.3.9/lunr.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lunr-flex@1.0.5/lunr.flex.min.js"></script>
<style>
:root {
--bg-primary: #1a1b26;
@@ -151,6 +153,10 @@ templ DocsLayout(nav Navigation, doc Document, user User) {
transform: translateX(-100%);
transition: transform 0.3s;
}
#search-results {
padding: 1rem;
padding-top: 4rem;
}
.sidebar.open {
transform: translateX(0);
}
@@ -162,11 +168,55 @@ templ DocsLayout(nav Navigation, doc Document, user User) {
display: block;
}
}
#search-results {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.95);
overflow-y: auto;
padding: 2rem;
z-index: 1000;
display: none;
}
.search-result {
display: block;
padding: 1rem;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
text-decoration: none;
}
.search-result:hover {
background: var(--bg-secondary);
}
.result-title {
font-weight: 600;
margin-bottom: 0.5rem;
}
.result-snippet {
font-size: 0.875rem;
color: var(--text-secondary);
}
.no-results {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
mark {
background: var(--accent);
color: white;
padding: 0 0.2rem;
border-radius: 2px;
}
</style>
</head>
<body>
<button class="mobile-menu-button" onclick="toggleSidebar()">
<!-- Search Results Overlay -->
<div id="search-results"></div>
</button>
<!-- Sidebar -->
@@ -270,21 +320,110 @@ templ DocsLayout(nav Navigation, doc Document, user User) {
// Initialize syntax highlighting
hljs.highlightAll();
});
// Search functionality
async function searchDocs(query) {
if (query.length < 2) return;
try {
const response = await fetch('/docs/api/search?q=' + encodeURIComponent(query));
const data = await response.json();
// Display search results (you can enhance this)
console.log('Search results:', data.results);
} catch (error) {
console.error('Search failed:', error);
// Search state
let idx;
let searchResults = [];
// Load index on page load
fetch('/docs/search-index.json')
.then(r => r.json())
.then(data => {
// Initialize Lunr with fuzzy matching
idx = lunr(function() {
this.use(lunr.flex)
this.ref('id')
this.field('title', {boost: 10})
this.field('content', {boost: 1})
data.forEach(doc => this.add(doc))
})
console.log('Search index loaded:', data.length, 'documents')
})
.catch(err => console.error('Failed to load search index:', err));
// Search input with debounce
const searchInput = document.getElementById('docs-search');
const searchResultsDiv = document.getElementById('search-results');
let debounceTimer;
searchInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const query = e.target.value.trim();
if (query.length < 2) {
searchResultsDiv.style.display = 'none';
return;
}
// Search with fuzzy matching
const results = idx.search(query, {
fields: {title: {boost: 10}, content: 1},
expand: true
});
// Store results for highlighting
searchResults = results;
// Render results
renderSearchResults(results, query);
}, 150);
});
// Render search results
function renderSearchResults(results, query) {
if (results.length === 0) {
searchResultsDiv.innerHTML = '<div class="no-results">No results found</div>';
searchResultsDiv.style.display = 'block';
return;
}
searchResultsDiv.innerHTML = results.map(r => {
const doc = getDocById(r.ref);
if (!doc) return '';
const snippet = getSnippet(doc.content, query);
return `
<a href="${doc.url}" class="search-result">
<div class="result-title">${doc.title}</div>
<div class="result-snippet">${snippet}...</div>
</a>
`;
}).join('');
searchResultsDiv.style.display = 'block';
}
function getDocById(id) {
// This would be populated from the index
// For now, return a placeholder
return {title: id, url: '/docs/' + id.replace('.md', ''), content: ''};
}
function getSnippet(content, query) {
// Simple snippet extraction
const words = query.toLowerCase().split(/\s+/);
const contentLower = content.toLowerCase();
for (const word of words) {
const idx = contentLower.indexOf(word);
if (idx !== -1) {
const start = Math.max(0, idx - 50);
const end = Math.min(content.length, idx + 100);
return '...' + content.substring(start, end) + '...';
}
}
return content.substring(0, 150) + '...';
}
// Close search results when clicking outside
document.addEventListener('click', (e) => {
if (!searchResultsDiv.contains(e.target) && e.target !== searchInput) {
searchResultsDiv.style.display = 'none';
}
});
</script>
</body>
</html>
@@ -299,6 +438,8 @@ templ DocsLayoutWithExplorer(nav Navigation, doc Document, user User, explorer A
<title>{ doc.Title } - Bookhoard Documentation</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lunr@2.3.9/lunr.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lunr-flex@1.0.5/lunr-flex.min.js"></script>
<style>
:root {
--bg-primary: #1a1b26;
@@ -436,6 +577,10 @@ templ DocsLayoutWithExplorer(nav Navigation, doc Document, user User, explorer A
transform: translateX(-100%);
transition: transform 0.3s;
}
#search-results {
padding: 1rem;
padding-top: 4rem;
}
.sidebar.open {
transform: translateX(0);
}
@@ -447,11 +592,55 @@ templ DocsLayoutWithExplorer(nav Navigation, doc Document, user User, explorer A
display: block;
}
}
#search-results {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.95);
overflow-y: auto;
padding: 2rem;
z-index: 1000;
display: none;
}
.search-result {
display: block;
padding: 1rem;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
text-decoration: none;
}
.search-result:hover {
background: var(--bg-secondary);
}
.result-title {
font-weight: 600;
margin-bottom: 0.5rem;
}
.result-snippet {
font-size: 0.875rem;
color: var(--text-secondary);
}
.no-results {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
mark {
background: var(--accent);
color: white;
padding: 0 0.2rem;
border-radius: 2px;
}
</style>
</head>
<body>
<button class="mobile-menu-button" onclick="toggleSidebar()">
<!-- Search Results Overlay -->
<div id="search-results"></div>
</button>
<!-- Sidebar -->
@@ -554,25 +743,114 @@ templ DocsLayoutWithExplorer(nav Navigation, doc Document, user User, explorer A
item.classList.add('active');
}
});
// Initialize syntax highlighting
hljs.highlightAll();
});
// Search functionality
async function searchDocs(query) {
if (query.length < 2) return;
try {
const response = await fetch('/docs/api/search?q=' + encodeURIComponent(query));
const data = await response.json();
// Display search results (you can enhance this)
console.log('Search results:', data.results);
} catch (error) {
console.error('Search failed:', error);
// Search state
let idx;
let searchResults = [];
// Load index on page load
fetch('/docs/search-index.json')
.then(r => r.json())
.then(data => {
// Initialize Lunr with fuzzy matching
idx = lunr(function() {
this.use(lunr.flex)
this.ref('id')
this.field('title', {boost: 10})
this.field('content', {boost: 1})
data.forEach(doc => this.add(doc))
})
console.log('Search index loaded:', data.length, 'documents')
})
.catch(err => console.error('Failed to load search index:', err));
// Search input with debounce
const searchInput = document.getElementById('docs-search');
const searchResultsDiv = document.getElementById('search-results');
let debounceTimer;
searchInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const query = e.target.value.trim();
if (query.length < 2) {
searchResultsDiv.style.display = 'none';
return;
}
// Search with fuzzy matching
const results = idx.search(query, {
fields: {title: {boost: 10}, content: 1},
expand: true
});
// Store results for highlighting
searchResults = results;
// Render results
renderSearchResults(results, query);
}, 150);
});
// Render search results
function renderSearchResults(results, query) {
if (results.length === 0) {
searchResultsDiv.innerHTML = '<div class="no-results">No results found</div>';
searchResultsDiv.style.display = 'block';
return;
}
searchResultsDiv.innerHTML = results.map(r => {
const doc = getDocById(r.ref);
if (!doc) return '';
const snippet = getSnippet(doc.content, query);
return `
<a href="${doc.url}" class="search-result">
<div class="result-title">${doc.title}</div>
<div class="result-snippet">${snippet}...</div>
</a>
`;
}).join('');
searchResultsDiv.style.display = 'block';
}
function getDocById(id) {
// This would be populated from the index
// For now, return a placeholder
return {title: id, url: '/docs/' + id.replace('.md', ''), content: ''};
}
function getSnippet(content, query) {
// Simple snippet extraction
const words = query.toLowerCase().split(/\s+/);
const contentLower = content.toLowerCase();
for (const word of words) {
const idx = contentLower.indexOf(word);
if (idx !== -1) {
const start = Math.max(0, idx - 50);
const end = Math.min(content.length, idx + 100);
return '...' + content.substring(start, end) + '...';
}
}
return content.substring(0, 150) + '...';
}
// Close search results when clicking outside
document.addEventListener('click', (e) => {
if (!searchResultsDiv.contains(e.target) && e.target !== searchInput) {
searchResultsDiv.style.display = 'none';
}
});
</script>
</body>
</html>