Replaced CDN dependencies with local downloads for fully self-contained operation: - Highlight.js (syntax highlighting) - 121KB - Highlight.js GitHub Dark theme - 1.3KB - Lunr.js (documentation search) - 29KB - Lunr-flex (search plugin) - 43KB Changes: - Updated package.json postinstall to download all dependencies - Modified docs templates to use /web/static/ paths - Updated .gitignore to allow documentation dependencies - Total added: ~195KB (minimal container impact) Benefits: - ✅ Fully self-hosted - no external CDN requests - ✅ Works offline without internet access - ✅ No privacy/analytics leaks from CDNs - ✅ Consistent with project's self-hosting philosophy - ✅ Improved reliability for air-gapped deployments Note: Tailwind CSS CDN remains (development-only, production uses compiled CSS)
741 lines
25 KiB
Templ
741 lines
25 KiB
Templ
package templates
|
||
|
||
import (
|
||
"fmt"
|
||
)
|
||
|
||
templ DocsLayout(nav Navigation, doc Document, user User) {
|
||
<!DOCTYPE html>
|
||
<html lang="en" class="dark">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{ doc.Title } - Bookhoard Documentation</title>
|
||
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
|
||
<link rel="stylesheet" href="/web/static/highlight-dark.min.css">
|
||
<script src="/web/static/highlight.min.js"></script>
|
||
<script src="/web/static/lunr.min.js"></script>
|
||
<script src="/web/static/lunr-flex.min.js"></script>
|
||
<script>
|
||
tailwind.config = {
|
||
darkMode: 'class',
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
background: {
|
||
primary: '#1a1b26',
|
||
secondary: '#24283b',
|
||
},
|
||
text: {
|
||
primary: '#c0caf5',
|
||
secondary: '#9aa5ce',
|
||
},
|
||
border: '#414868',
|
||
accent: '#7aa2f7',
|
||
},
|
||
typography: ({ theme }) => ({
|
||
invert: {
|
||
css: {
|
||
code: {
|
||
backgroundColor: theme('colors.background.primary'),
|
||
color: theme('colors.text.primary'),
|
||
},
|
||
'pre code': {
|
||
backgroundColor: 'transparent',
|
||
},
|
||
pre: {
|
||
backgroundColor: '#14151f',
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
</head>
|
||
<body class="bg-background-primary text-text-primary font-sans antialiased">
|
||
<!-- Mobile Menu Button -->
|
||
<button
|
||
class="lg:hidden fixed top-4 left-4 z-50 bg-background-secondary border border-border rounded p-2 text-text-primary hover:bg-background-secondary/80"
|
||
onclick="toggleSidebar()"
|
||
aria-label="Toggle menu"
|
||
>
|
||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- Search Results Overlay -->
|
||
<div id="search-results" class="hidden fixed inset-0 bg-black/95 z-50 overflow-y-auto p-8 transition-opacity duration-200 ease-in-out"></div>
|
||
|
||
<!-- Sidebar -->
|
||
<div class="sidebar fixed left-0 top-0 bottom-0 w-72 bg-background-secondary border-r border-border overflow-y-auto z-40 lg:translate-x-0 transition-transform duration-300 ease-in-out">
|
||
<!-- Search -->
|
||
<div class="p-4 border-b border-border">
|
||
<input
|
||
type="text"
|
||
class="search-input w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent text-sm"
|
||
placeholder="Search documentation..."
|
||
id="docs-search"
|
||
oninput="searchDocs(this.value)"
|
||
>
|
||
</div>
|
||
|
||
<!-- Navigation Sections -->
|
||
for _, section := range nav.Sections {
|
||
<div class="nav-section mb-6">
|
||
<div
|
||
class="nav-section-title font-semibold text-xs uppercase tracking-wider text-text-secondary px-4 py-3 cursor-pointer select-none flex items-center justify-between"
|
||
onclick="toggleSection(this)"
|
||
>
|
||
<span>{ section.Title }</span>
|
||
<svg class="w-4 h-4 transform transition-transform section-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||
</svg>
|
||
</div>
|
||
if section.Collapsed {
|
||
<div class="nav-items hidden" data-collapsed="true">
|
||
for _, item := range section.Items {
|
||
<a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline">
|
||
<span class="mr-2">{ item.Icon }</span>{ item.Title }
|
||
</a>
|
||
}
|
||
</div>
|
||
} else {
|
||
<div class="nav-items" data-collapsed="false">
|
||
for _, item := range section.Items {
|
||
<a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline">
|
||
<span class="mr-2">{ item.Icon }</span>{ item.Title }
|
||
</a>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<!-- Main Content -->
|
||
<main class="main-content lg:ml-72 p-8 max-w-4xl mx-auto">
|
||
<!-- Breadcrumb -->
|
||
if len(doc.Breadcrumb) > 0 {
|
||
<nav class="breadcrumb flex gap-2 text-sm text-text-secondary mb-8" aria-label="Breadcrumb">
|
||
for i, crumb := range doc.Breadcrumb {
|
||
if i > 0 {
|
||
<span class="text-text-secondary/50">›</span>
|
||
}
|
||
<a href={ crumb.URL } class="text-accent hover:underline no-underline">{ crumb.Title }</a>
|
||
}
|
||
</nav>
|
||
}
|
||
|
||
<!-- Title -->
|
||
<h1 class="text-4xl font-bold mb-8 text-text-primary">{ doc.Title }</h1>
|
||
|
||
<!-- Table of Contents -->
|
||
if len(doc.TOC) > 0 {
|
||
<div class="toc bg-background-secondary p-4 rounded-lg mb-8 border border-border">
|
||
<strong class="block mb-2 text-text-primary font-semibold">On this page</strong>
|
||
for _, item := range doc.TOC {
|
||
<a
|
||
href={ "#" + item.Anchor }
|
||
class="toc-item block py-1 text-text-secondary hover:text-accent text-sm no-underline"
|
||
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) }
|
||
>
|
||
{ item.Title }
|
||
</a>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
<!-- Content -->
|
||
<div class="prose prose-invert max-w-none">
|
||
@UnsafeHTML(doc.Content).ToComponent()
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
// Toggle sidebar section
|
||
function toggleSection(el) {
|
||
const items = el.nextElementSibling;
|
||
const arrow = el.querySelector('.section-arrow');
|
||
const isCollapsed = items.getAttribute('data-collapsed') === 'true';
|
||
|
||
if (isCollapsed) {
|
||
items.classList.remove('hidden');
|
||
items.setAttribute('data-collapsed', 'false');
|
||
arrow.style.transform = 'rotate(0deg)';
|
||
} else {
|
||
items.classList.add('hidden');
|
||
items.setAttribute('data-collapsed', 'true');
|
||
arrow.style.transform = 'rotate(-90deg)';
|
||
}
|
||
}
|
||
|
||
// Toggle sidebar on mobile
|
||
function toggleSidebar() {
|
||
const sidebar = document.querySelector('.sidebar');
|
||
sidebar.classList.toggle('open');
|
||
}
|
||
|
||
// Close sidebar when clicking main content on mobile
|
||
document.querySelector('.main-content')?.addEventListener('click', () => {
|
||
const sidebar = document.querySelector('.sidebar');
|
||
if (window.innerWidth < 1024) {
|
||
sidebar.classList.remove('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('text-accent', 'bg-accent/10', 'border-r-2', 'border-accent');
|
||
}
|
||
});
|
||
|
||
// Initialize syntax highlighting
|
||
if (typeof hljs !== 'undefined') {
|
||
hljs.highlightAll();
|
||
|
||
// Add copy buttons to all code blocks
|
||
document.querySelectorAll('pre code').forEach((block) => {
|
||
const pre = block.parentElement;
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'relative group';
|
||
pre.parentNode.insertBefore(wrapper, pre);
|
||
wrapper.appendChild(pre);
|
||
|
||
const button = document.createElement('button');
|
||
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent';
|
||
button.textContent = 'Copy';
|
||
button.onclick = async () => {
|
||
await navigator.clipboard.writeText(block.textContent);
|
||
button.textContent = 'Copied!';
|
||
setTimeout(() => {
|
||
button.textContent = 'Copy';
|
||
}, 2000);
|
||
};
|
||
wrapper.appendChild(button);
|
||
});
|
||
}
|
||
|
||
// Add copy buttons to all code blocks
|
||
document.querySelectorAll('pre code').forEach((block) => {
|
||
const pre = block.parentElement;
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'relative group';
|
||
pre.parentNode.insertBefore(wrapper, pre);
|
||
wrapper.appendChild(pre);
|
||
|
||
const button = document.createElement('button');
|
||
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent';
|
||
button.textContent = 'Copy';
|
||
button.onclick = async () => {
|
||
await navigator.clipboard.writeText(block.textContent);
|
||
button.textContent = 'Copied!';
|
||
setTimeout(() => {
|
||
button.textContent = 'Copy';
|
||
}, 2000);
|
||
};
|
||
wrapper.appendChild(button);
|
||
});
|
||
});
|
||
|
||
// Add copy buttons to all code blocks
|
||
document.querySelectorAll('pre code').forEach((block) => {
|
||
const pre = block.parentElement;
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'relative group';
|
||
pre.parentNode.insertBefore(wrapper, pre);
|
||
wrapper.appendChild(pre);
|
||
|
||
const button = document.createElement('button');
|
||
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent';
|
||
button.textContent = 'Copy';
|
||
button.onclick = async () => {
|
||
await navigator.clipboard.writeText(block.textContent);
|
||
button.textContent = 'Copied!';
|
||
setTimeout(() => {
|
||
button.textContent = 'Copy';
|
||
}, 2000);
|
||
};
|
||
wrapper.appendChild(button);
|
||
});
|
||
|
||
// Search state
|
||
let idx;
|
||
let searchDocs = [];
|
||
|
||
// Load index on page load
|
||
fetch('/docs/search-index.json')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
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));
|
||
});
|
||
searchDocs = data;
|
||
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.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
// Search with fuzzy matching
|
||
const results = idx.search(query, {
|
||
fields: {title: {boost: 10}, content: 1},
|
||
expand: true
|
||
});
|
||
|
||
// Render results
|
||
renderSearchResults(results, query);
|
||
}, 150);
|
||
});
|
||
|
||
// Render search results
|
||
function renderSearchResults(results, query) {
|
||
if (results.length === 0) {
|
||
searchResultsDiv.innerHTML = '<div class="text-center py-8 text-text-secondary">No results found</div>';
|
||
searchResultsDiv.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
searchResultsDiv.innerHTML = results.map(r => {
|
||
const doc = searchDocs.find(d => d.id === r.ref);
|
||
if (!doc) return '';
|
||
|
||
const snippet = getSnippet(doc.content, query);
|
||
|
||
return `
|
||
<a href="${doc.url}" class="block p-4 border-b border-border hover:bg-background-secondary text-text-primary no-underline">
|
||
<div class="font-semibold mb-2">${doc.title}</div>
|
||
<div class="text-sm text-text-secondary">${snippet}...</div>
|
||
</a>
|
||
`;
|
||
}).join('');
|
||
|
||
searchResultsDiv.classList.remove('hidden');
|
||
}
|
||
|
||
function getSnippet(content, query) {
|
||
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.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
// Close search on Escape key
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
searchResultsDiv.classList.add('hidden');
|
||
searchInput.blur();
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
}
|
||
|
||
templ DocsLayoutWithExplorer(nav Navigation, doc Document, user User, explorer APIExplorerData) {
|
||
<!DOCTYPE html>
|
||
<html lang="en" class="dark">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{ doc.Title } - Bookhoard Documentation</title>
|
||
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
|
||
<link rel="stylesheet" href="/web/static/highlight-dark.min.css">
|
||
<script src="/web/static/highlight.min.js"></script>
|
||
<script src="/web/static/lunr.min.js"></script>
|
||
<script src="/web/static/lunr-flex.min.js"></script>
|
||
<script>
|
||
tailwind.config = {
|
||
darkMode: 'class',
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
background: {
|
||
primary: '#1a1b26',
|
||
secondary: '#24283b',
|
||
},
|
||
text: {
|
||
primary: '#c0caf5',
|
||
secondary: '#9aa5ce',
|
||
},
|
||
border: '#414868',
|
||
accent: '#7aa2f7',
|
||
},
|
||
typography: ({ theme }) => ({
|
||
invert: {
|
||
css: {
|
||
code: {
|
||
backgroundColor: theme('colors.background.primary'),
|
||
color: theme('colors.text.primary'),
|
||
},
|
||
'pre code': {
|
||
backgroundColor: 'transparent',
|
||
},
|
||
pre: {
|
||
backgroundColor: '#14151f',
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
</head>
|
||
<body class="bg-background-primary text-text-primary font-sans antialiased">
|
||
<!-- Mobile Menu Button -->
|
||
<button
|
||
class="lg:hidden fixed top-4 left-4 z-50 bg-background-secondary border border-border rounded p-2 text-text-primary hover:bg-background-secondary/80"
|
||
onclick="toggleSidebar()"
|
||
aria-label="Toggle menu"
|
||
>
|
||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- Search Results Overlay -->
|
||
<div id="search-results" class="hidden fixed inset-0 bg-black/95 z-50 overflow-y-auto p-4 lg:p-8 transition-opacity duration-200 ease-in-out"></div>
|
||
|
||
<!-- Sidebar -->
|
||
<div class="sidebar fixed left-0 top-0 bottom-0 w-72 bg-background-secondary border-r border-border overflow-y-auto z-40 lg:translate-x-0 transition-transform duration-300 ease-in-out">
|
||
<!-- Search -->
|
||
<div class="p-4 border-b border-border">
|
||
<input
|
||
type="text"
|
||
class="search-input w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent text-sm"
|
||
placeholder="Search documentation..."
|
||
id="docs-search"
|
||
oninput="searchDocs(this.value)"
|
||
>
|
||
</div>
|
||
|
||
<!-- Navigation Sections -->
|
||
for _, section := range nav.Sections {
|
||
<div class="nav-section mb-6">
|
||
<div
|
||
class="nav-section-title font-semibold text-xs uppercase tracking-wider text-text-secondary px-4 py-3 cursor-pointer select-none flex items-center justify-between"
|
||
onclick="toggleSection(this)"
|
||
>
|
||
<span>{ section.Title }</span>
|
||
<svg class="w-4 h-4 transform transition-transform section-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||
</svg>
|
||
</div>
|
||
if section.Collapsed {
|
||
<div class="nav-items hidden" data-collapsed="true">
|
||
for _, item := range section.Items {
|
||
<a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline">
|
||
<span class="mr-2">{ item.Icon }</span>{ item.Title }
|
||
</a>
|
||
}
|
||
</div>
|
||
} else {
|
||
<div class="nav-items" data-collapsed="false">
|
||
for _, item := range section.Items {
|
||
<a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline">
|
||
<span class="mr-2">{ item.Icon }</span>{ item.Title }
|
||
</a>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<!-- Main Content -->
|
||
<main class="main-content lg:ml-72 p-4 lg:p-8 max-w-4xl mx-auto">
|
||
<!-- Breadcrumb -->
|
||
if len(doc.Breadcrumb) > 0 {
|
||
<nav class="breadcrumb flex flex-wrap gap-2 text-sm text-text-secondary mb-8" aria-label="Breadcrumb">
|
||
for i, crumb := range doc.Breadcrumb {
|
||
if i > 0 {
|
||
<span class="text-text-secondary/50">›</span>
|
||
}
|
||
<a href={ crumb.URL } class="text-accent hover:underline no-underline">{ crumb.Title }</a>
|
||
}
|
||
</nav>
|
||
}
|
||
|
||
<!-- Title -->
|
||
<h1 class="text-3xl lg:text-4xl font-bold mb-6 lg:mb-8 text-text-primary">{ doc.Title }</h1>
|
||
|
||
<!-- Table of Contents -->
|
||
if len(doc.TOC) > 0 {
|
||
<div class="toc bg-background-secondary p-4 rounded-lg mb-6 lg:mb-8 border border-border lg:static">
|
||
<strong class="block mb-2 text-text-primary font-semibold">On this page</strong>
|
||
for _, item := range doc.TOC {
|
||
<a
|
||
href={ "#" + item.Anchor }
|
||
class="toc-item block py-1 text-text-secondary hover:text-accent text-sm no-underline"
|
||
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) }
|
||
>
|
||
{ item.Title }
|
||
</a>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
<!-- Content -->
|
||
<div class="prose prose-invert prose-sm lg:prose-base max-w-none">
|
||
@UnsafeHTML(doc.Content).ToComponent()
|
||
</div>
|
||
|
||
<!-- API Explorer -->
|
||
@APIExplorer(explorer)
|
||
</main>
|
||
|
||
<script>
|
||
// Toggle sidebar section
|
||
function toggleSection(el) {
|
||
const items = el.nextElementSibling;
|
||
const arrow = el.querySelector('.section-arrow');
|
||
const isCollapsed = items.getAttribute('data-collapsed') === 'true';
|
||
|
||
if (isCollapsed) {
|
||
items.classList.remove('hidden');
|
||
items.setAttribute('data-collapsed', 'false');
|
||
arrow.style.transform = 'rotate(0deg)';
|
||
} else {
|
||
items.classList.add('hidden');
|
||
items.setAttribute('data-collapsed', 'true');
|
||
arrow.style.transform = 'rotate(-90deg)';
|
||
}
|
||
}
|
||
|
||
// Toggle sidebar on mobile
|
||
function toggleSidebar() {
|
||
const sidebar = document.querySelector('.sidebar');
|
||
sidebar.classList.toggle('open');
|
||
}
|
||
|
||
// Close sidebar when clicking main content on mobile
|
||
document.querySelector('.main-content')?.addEventListener('click', () => {
|
||
const sidebar = document.querySelector('.sidebar');
|
||
if (window.innerWidth < 1024) {
|
||
sidebar.classList.remove('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('text-accent', 'bg-accent/10', 'border-r-2', 'border-accent');
|
||
}
|
||
});
|
||
|
||
// Initialize syntax highlighting
|
||
if (typeof hljs !== 'undefined') {
|
||
hljs.highlightAll();
|
||
|
||
// Add copy buttons to all code blocks
|
||
document.querySelectorAll('pre code').forEach((block) => {
|
||
const pre = block.parentElement;
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'relative group';
|
||
pre.parentNode.insertBefore(wrapper, pre);
|
||
wrapper.appendChild(pre);
|
||
|
||
const button = document.createElement('button');
|
||
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent';
|
||
button.textContent = 'Copy';
|
||
button.onclick = async () => {
|
||
await navigator.clipboard.writeText(block.textContent);
|
||
button.textContent = 'Copied!';
|
||
setTimeout(() => {
|
||
button.textContent = 'Copy';
|
||
}, 2000);
|
||
};
|
||
wrapper.appendChild(button);
|
||
});
|
||
}
|
||
|
||
// Add copy buttons to all code blocks
|
||
document.querySelectorAll('pre code').forEach((block) => {
|
||
const pre = block.parentElement;
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'relative group';
|
||
pre.parentNode.insertBefore(wrapper, pre);
|
||
wrapper.appendChild(pre);
|
||
|
||
const button = document.createElement('button');
|
||
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent';
|
||
button.textContent = 'Copy';
|
||
button.onclick = async () => {
|
||
await navigator.clipboard.writeText(block.textContent);
|
||
button.textContent = 'Copied!';
|
||
setTimeout(() => {
|
||
button.textContent = 'Copy';
|
||
}, 2000);
|
||
};
|
||
wrapper.appendChild(button);
|
||
});
|
||
});
|
||
|
||
// Add copy buttons to all code blocks
|
||
document.querySelectorAll('pre code').forEach((block) => {
|
||
const pre = block.parentElement;
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'relative group';
|
||
pre.parentNode.insertBefore(wrapper, pre);
|
||
wrapper.appendChild(pre);
|
||
|
||
const button = document.createElement('button');
|
||
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent';
|
||
button.textContent = 'Copy';
|
||
button.onclick = async () => {
|
||
await navigator.clipboard.writeText(block.textContent);
|
||
button.textContent = 'Copied!';
|
||
setTimeout(() => {
|
||
button.textContent = 'Copy';
|
||
}, 2000);
|
||
};
|
||
wrapper.appendChild(button);
|
||
});
|
||
|
||
// Search state
|
||
let idx;
|
||
let searchDocs = [];
|
||
|
||
// Load index on page load
|
||
fetch('/docs/search-index.json')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
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));
|
||
});
|
||
searchDocs = data;
|
||
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.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
// Search with fuzzy matching
|
||
const results = idx.search(query, {
|
||
fields: {title: {boost: 10}, content: 1},
|
||
expand: true
|
||
});
|
||
|
||
// Render results
|
||
renderSearchResults(results, query);
|
||
}, 150);
|
||
});
|
||
|
||
// Render search results
|
||
function renderSearchResults(results, query) {
|
||
if (results.length === 0) {
|
||
searchResultsDiv.innerHTML = '<div class="text-center py-8 text-text-secondary">No results found</div>';
|
||
searchResultsDiv.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
searchResultsDiv.innerHTML = results.map(r => {
|
||
const doc = searchDocs.find(d => d.id === r.ref);
|
||
if (!doc) return '';
|
||
|
||
const snippet = getSnippet(doc.content, query);
|
||
|
||
return `
|
||
<a href="${doc.url}" class="block p-4 border-b border-border hover:bg-background-secondary text-text-primary no-underline">
|
||
<div class="font-semibold mb-2">${doc.title}</div>
|
||
<div class="text-sm text-text-secondary">${snippet}...</div>
|
||
</a>
|
||
`;
|
||
}).join('');
|
||
|
||
searchResultsDiv.classList.remove('hidden');
|
||
}
|
||
|
||
function getSnippet(content, query) {
|
||
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.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
// Close search on Escape key
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
searchResultsDiv.classList.add('hidden');
|
||
searchInput.blur();
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
}
|