Files
bookhoard/templates/docs.templ
T
john-okeefe 542fbaf116 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
2026-02-02 09:08:37 -05:00

858 lines
22 KiB
Templ
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package templates
import (
"fmt"
"html/template"
)
templ DocsLayout(nav Navigation, doc Document, user User) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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;
--bg-secondary: #24283b;
--text-primary: #c0caf5;
--text-secondary: #9aa5ce;
--border: #414868;
--accent: #7aa2f7;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
margin: 0;
padding: 0;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 280px;
background-color: var(--bg-secondary);
border-right: 1px solid var(--border);
overflow-y: auto;
z-index: 50;
}
.main-content {
margin-left: 280px;
padding: 2rem;
max-width: 900px;
}
.nav-section {
margin-bottom: 1.5rem;
}
.nav-section-title {
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
padding: 0.75rem 1rem;
cursor: pointer;
user-select: none;
}
.nav-item {
display: block;
padding: 0.5rem 1rem 0.5rem 2rem;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
transition: color 0.2s;
}
.nav-item:hover {
color: var(--accent);
}
.nav-item.active {
color: var(--accent);
background-color: rgba(122, 162, 247, 0.1);
border-right: 2px solid var(--accent);
}
pre {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem;
overflow-x: auto;
}
code {
background-color: rgba(122, 162, 247, 0.1);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
.breadcrumb {
display: flex;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 2rem;
}
.breadcrumb a {
color: var(--accent);
text-decoration: none;
}
.toc {
background-color: var(--bg-secondary);
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 2rem;
border: 1px solid var(--border);
}
.toc-item {
padding: 0.25rem 0;
display: block;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
}
.toc-item:hover {
color: var(--accent);
}
.search-box {
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
background-color: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
font-size: 0.875rem;
}
.search-input:focus {
outline: none;
border-color: var(--accent);
}
.mobile-menu-button {
display: none;
position: fixed;
top: 1rem;
left: 1rem;
z-index: 100;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.375rem;
padding: 0.5rem;
color: var(--text-primary);
cursor: pointer;
}
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s;
}
#search-results {
padding: 1rem;
padding-top: 4rem;
}
.sidebar.open {
transform: translateX(0);
}
.main-content {
margin-left: 0;
padding: 1rem;
}
.mobile-menu-button {
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 -->
<div class="sidebar" id="sidebar">
<!-- Search -->
<div class="search-box">
<input type="text"
class="search-input"
placeholder="Search documentation..."
id="docs-search"
oninput="searchDocs(this.value)">
</div>
<!-- Navigation Sections -->
for _, section := range nav.Sections {
<div class="nav-section">
<div class="nav-section-title" onclick="toggleSection(this)">
{ section.Title }
</div>
if section.Collapsed {
<div class="nav-items" style="display: none;">
for _, item := range section.Items {
<a href={ item.URL } class="nav-item">
{ item.Icon } { item.Title }
</a>
}
</div>
} else {
<div class="nav-items">
for _, item := range section.Items {
<a href={ item.URL } class="nav-item">
{ item.Icon } { item.Title }
</a>
}
</div>
}
</div>
}
</div>
<!-- Main Content -->
<div class="main-content">
<!-- Breadcrumb -->
if len(doc.Breadcrumb) > 0 {
<div class="breadcrumb">
for i, crumb := range doc.Breadcrumb {
if i > 0 {
<span></span>
}
<a href={ crumb.URL }>{ crumb.Title }</a>
}
</div>
}
<!-- Title -->
<h1 style="margin-bottom: 2rem;">{ doc.Title }</h1>
<!-- Table of Contents -->
if len(doc.TOC) > 0 {
<div class="toc">
<strong style="display: block; margin-bottom: 0.5rem; color: var(--text-primary);">On this page</strong>
for _, item := range doc.TOC {
<a href={ "#" + item.Anchor } class="toc-item" style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) }>
{ item.Title }
</a>
}
</div>
}
<!-- Content -->
<div>
@UnsafeHTML(doc.Content).ToComponent()
</div>
</div>
<script>
// Toggle sidebar section
function toggleSection(el) {
const items = el.nextElementSibling;
if (items.style.display === 'none') {
items.style.display = 'block';
} else {
items.style.display = 'none';
}
}
// Toggle sidebar on mobile
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
}
// Highlight current page in nav
document.addEventListener('DOMContentLoaded', function() {
const currentPath = window.location.pathname;
document.querySelectorAll('.nav-item').forEach(item => {
if (item.getAttribute('href') === currentPath) {
item.classList.add('active');
}
});
// Initialize syntax highlighting
hljs.highlightAll();
});
// 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>
}
templ DocsLayoutWithExplorer(nav Navigation, doc Document, user User, explorer APIExplorerData) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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;
--bg-secondary: #24283b;
--text-primary: #c0caf5;
--text-secondary: #9aa5ce;
--border: #414868;
--accent: #7aa2f7;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
margin: 0;
padding: 0;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 280px;
background-color: var(--bg-secondary);
border-right: 1px solid var(--border);
overflow-y: auto;
z-index: 50;
}
.main-content {
margin-left: 280px;
padding: 2rem;
max-width: 900px;
}
.nav-section {
margin-bottom: 1.5rem;
}
.nav-section-title {
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
padding: 0.75rem 1rem;
cursor: pointer;
user-select: none;
}
.nav-item {
display: block;
padding: 0.5rem 1rem 0.5rem 2rem;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
transition: color 0.2s;
}
.nav-item:hover {
color: var(--accent);
}
.nav-item.active {
color: var(--accent);
background-color: rgba(122, 162, 247, 0.1);
border-right: 2px solid var(--accent);
}
pre {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem;
overflow-x: auto;
}
code {
background-color: rgba(122, 162, 247, 0.1);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
.breadcrumb {
display: flex;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 2rem;
}
.breadcrumb a {
color: var(--accent);
text-decoration: none;
}
.toc {
background-color: var(--bg-secondary);
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 2rem;
border: 1px solid var(--border);
}
.toc-item {
padding: 0.25rem 0;
display: block;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
}
.toc-item:hover {
color: var(--accent);
}
.search-box {
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
background-color: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
font-size: 0.875rem;
}
.search-input:focus {
outline: none;
border-color: var(--accent);
}
.mobile-menu-button {
display: none;
position: fixed;
top: 1rem;
left: 1rem;
z-index: 100;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.375rem;
padding: 0.5rem;
color: var(--text-primary);
cursor: pointer;
}
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s;
}
#search-results {
padding: 1rem;
padding-top: 4rem;
}
.sidebar.open {
transform: translateX(0);
}
.main-content {
margin-left: 0;
padding: 1rem;
}
.mobile-menu-button {
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 -->
<div class="sidebar" id="sidebar">
<!-- Search -->
<div class="search-box">
<input type="text"
class="search-input"
placeholder="Search documentation..."
id="docs-search"
oninput="searchDocs(this.value)">
</div>
<!-- Navigation Sections -->
for _, section := range nav.Sections {
<div class="nav-section">
<div class="nav-section-title" onclick="toggleSection(this)">
{ section.Title }
</div>
if section.Collapsed {
<div class="nav-items" style="display: none;">
for _, item := range section.Items {
<a href={ item.URL } class="nav-item">
{ item.Icon } { item.Title }
</a>
}
</div>
} else {
<div class="nav-items">
for _, item := range section.Items {
<a href={ item.URL } class="nav-item">
{ item.Icon } { item.Title }
</a>
}
</div>
}
</div>
}
</div>
<!-- Main Content -->
<div class="main-content">
<!-- Breadcrumb -->
if len(doc.Breadcrumb) > 0 {
<div class="breadcrumb">
for i, crumb := range doc.Breadcrumb {
if i > 0 {
<span></span>
}
<a href={ crumb.URL }>{ crumb.Title }</a>
}
</div>
}
<!-- Title -->
<h1 style="margin-bottom: 2rem;">{ doc.Title }</h1>
<!-- Table of Contents -->
if len(doc.TOC) > 0 {
<div class="toc">
<strong style="display: block; margin-bottom: 0.5rem; color: var(--text-primary);">On this page</strong>
for _, item := range doc.TOC {
<a href={ "#" + item.Anchor } class="toc-item" style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) }>
{ item.Title }
</a>
}
</div>
}
<!-- Content -->
<div>
@UnsafeHTML(doc.Content).ToComponent()
</div>
<!-- API Explorer -->
@APIExplorer(explorer)
</div>
<script>
// Toggle sidebar section
function toggleSection(el) {
const items = el.nextElementSibling;
if (items.style.display === 'none') {
items.style.display = 'block';
} else {
items.style.display = 'none';
}
}
// Toggle sidebar on mobile
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
}
// Highlight current page in nav
document.addEventListener('DOMContentLoaded', function() {
const currentPath = window.location.pathname;
document.querySelectorAll('.nav-item').forEach(item => {
if (item.getAttribute('href') === currentPath) {
item.classList.add('active');
}
});
// Initialize syntax highlighting
hljs.highlightAll();
});
// 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>
}