Skip to content

Commit 608cb82

Browse files
michaelmcneesclaude
andcommitted
feat(docs): add search with Pagefind and Cmd+K shortcut (closes #5)
- Pagefind indexes all 28 doc pages at build time - Search modal with Cmd+K / Ctrl+K keyboard shortcut - Lazy-loads Pagefind JS on first search (no upfront cost) - 200ms debounced input, up to 8 results with title + excerpt - Search trigger in desktop nav (docs pages) and sidebar top - Landing page excluded from index (data-pagefind-ignore) - Styled with Terminal Green design tokens Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 376a26c commit 608cb82

8 files changed

Lines changed: 257 additions & 3 deletions

File tree

site/package-lock.json

Lines changed: 96 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
"type": "module",
66
"scripts": {
77
"dev": "astro dev",
8-
"build": "astro build",
8+
"build": "astro build && npx pagefind --site dist",
99
"preview": "astro preview"
1010
},
1111
"dependencies": {
1212
"@astrojs/mdx": "^5.0.2",
1313
"@astrojs/sitemap": "^3.7.1",
1414
"@tailwindcss/vite": "^4.2.2",
1515
"astro": "^6.0.8",
16+
"pagefind": "^1.4.0",
1617
"tailwindcss": "^4.2.2"
1718
}
1819
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
---
2+
---
3+
<div class="relative" id="search-container">
4+
<button
5+
id="search-trigger"
6+
class="flex items-center gap-2 px-3 py-1.5 text-xs text-on-surface-variant border border-outline-variant bg-surface-container-low hover:border-outline transition-colors font-mono w-full lg:w-48"
7+
type="button"
8+
>
9+
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
11+
</svg>
12+
<span class="flex-1 text-left">Search docs...</span>
13+
<kbd class="hidden lg:inline text-[10px] text-on-surface-variant/60 border border-outline-variant/50 px-1 rounded-sm">⌘K</kbd>
14+
</button>
15+
</div>
16+
17+
<!-- Search modal -->
18+
<div id="search-modal" class="fixed inset-0 z-[100] hidden">
19+
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" id="search-backdrop"></div>
20+
<div class="fixed top-[15%] left-1/2 -translate-x-1/2 w-full max-w-xl bg-surface-container border border-outline-variant shadow-2xl max-h-[60vh] flex flex-col">
21+
<div class="flex items-center gap-3 px-4 py-3 border-b border-outline-variant">
22+
<svg class="w-4 h-4 text-on-surface-variant shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
23+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
24+
</svg>
25+
<input
26+
id="search-input"
27+
type="text"
28+
placeholder="Search documentation..."
29+
class="flex-1 bg-transparent text-on-surface text-sm outline-none placeholder:text-on-surface-variant/50 font-body"
30+
autocomplete="off"
31+
/>
32+
<kbd class="text-[10px] text-on-surface-variant/60 border border-outline-variant/50 px-1.5 py-0.5 rounded-sm">Esc</kbd>
33+
</div>
34+
<div id="search-results" class="overflow-y-auto p-2 text-sm">
35+
<p class="px-3 py-4 text-on-surface-variant/60 text-center text-xs">Type to search...</p>
36+
</div>
37+
</div>
38+
</div>
39+
40+
<script is:inline>
41+
function initSearch() {
42+
const trigger = document.getElementById('search-trigger');
43+
const modal = document.getElementById('search-modal');
44+
const backdrop = document.getElementById('search-backdrop');
45+
const input = document.getElementById('search-input');
46+
const results = document.getElementById('search-results');
47+
if (!trigger || !modal || !input || !results) return;
48+
49+
let pagefind = null;
50+
51+
async function loadPagefind() {
52+
if (pagefind) return pagefind;
53+
try {
54+
pagefind = await import('/pagefind/pagefind.js');
55+
await pagefind.init();
56+
} catch (e) {
57+
console.warn('Pagefind not available (run build first):', e);
58+
pagefind = null;
59+
}
60+
return pagefind;
61+
}
62+
63+
function openSearch() {
64+
modal.classList.remove('hidden');
65+
input.value = '';
66+
input.focus();
67+
results.innerHTML = '<p class="px-3 py-4 text-on-surface-variant/60 text-center text-xs">Type to search...</p>';
68+
}
69+
70+
function closeSearch() {
71+
modal.classList.add('hidden');
72+
}
73+
74+
trigger.onclick = openSearch;
75+
backdrop.onclick = closeSearch;
76+
77+
// Cmd+K / Ctrl+K shortcut
78+
document.addEventListener('keydown', (e) => {
79+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
80+
e.preventDefault();
81+
if (modal.classList.contains('hidden')) openSearch();
82+
else closeSearch();
83+
}
84+
if (e.key === 'Escape') closeSearch();
85+
});
86+
87+
let debounceTimer;
88+
input.addEventListener('input', () => {
89+
clearTimeout(debounceTimer);
90+
debounceTimer = setTimeout(async () => {
91+
const query = input.value.trim();
92+
if (!query) {
93+
results.innerHTML = '<p class="px-3 py-4 text-on-surface-variant/60 text-center text-xs">Type to search...</p>';
94+
return;
95+
}
96+
97+
const pf = await loadPagefind();
98+
if (!pf) {
99+
results.innerHTML = '<p class="px-3 py-4 text-on-surface-variant/60 text-center text-xs">Search unavailable (build required)</p>';
100+
return;
101+
}
102+
103+
const search = await pf.search(query);
104+
if (search.results.length === 0) {
105+
results.innerHTML = '<p class="px-3 py-4 text-on-surface-variant/60 text-center text-xs">No results for &quot;' + query.replace(/[<>"&]/g, '') + '&quot;</p>';
106+
return;
107+
}
108+
109+
const items = await Promise.all(search.results.slice(0, 8).map(r => r.data()));
110+
const container = document.createElement('div');
111+
items.forEach(item => {
112+
const link = document.createElement('a');
113+
link.href = item.url;
114+
link.className = 'block px-3 py-2 hover:bg-surface-container-high transition-colors group';
115+
116+
const title = document.createElement('div');
117+
title.className = 'font-medium text-on-surface text-sm group-hover:text-primary transition-colors';
118+
title.textContent = item.meta?.title || item.url;
119+
link.appendChild(title);
120+
121+
const excerpt = document.createElement('div');
122+
excerpt.className = 'text-xs text-on-surface-variant mt-0.5 line-clamp-2';
123+
// Pagefind excerpt contains <mark> tags from our own build index — safe to render
124+
excerpt.innerHTML = item.excerpt;
125+
link.appendChild(excerpt);
126+
127+
link.addEventListener('click', closeSearch);
128+
container.appendChild(link);
129+
});
130+
131+
results.replaceChildren(...container.childNodes);
132+
}, 200);
133+
});
134+
}
135+
initSearch();
136+
document.addEventListener('astro:after-swap', initSearch);
137+
</script>

site/src/components/DocsSidebar.astro

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
import { docsNav } from '../data/docs-nav';
33
import type { NavItem } from '../data/docs-nav';
4+
import DocsSearch from './DocsSearch.astro';
45
56
interface Props {
67
currentPath: string;
@@ -20,6 +21,9 @@ function isParentActive(item: NavItem): boolean {
2021

2122
<aside id="docs-sidebar" class="docs-sidebar hidden lg:block w-60 shrink-0">
2223
<div class="sticky top-16 h-[calc(100vh-4rem)] overflow-y-auto py-8 pr-4 pl-2">
24+
<div class="mb-6">
25+
<DocsSearch />
26+
</div>
2327
<nav aria-label="Documentation sidebar">
2428
{docsNav.map((group) => (
2529
<div class="mb-8">

site/src/components/Nav.astro

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
---
2+
import DocsSearch from './DocsSearch.astro';
3+
24
interface Props {
35
isHome?: boolean;
46
}
@@ -21,6 +23,13 @@ const isDocs = !isHome;
2123
<a href="/docs/getting-started" class:list={["text-sm transition-colors", isDocs ? "text-primary font-medium" : "text-on-surface-variant hover:text-on-surface"]}>Docs</a>
2224
</div>
2325

26+
<!-- Search (desktop, docs pages only) -->
27+
{isDocs && (
28+
<div class="hidden md:block">
29+
<DocsSearch />
30+
</div>
31+
)}
32+
2433
<!-- Right side -->
2534
<div class="flex items-center gap-4">
2635
<a href="https://github.com/dvflw/mantle" class="text-on-surface-variant hover:text-on-surface transition-colors" aria-label="GitHub">

site/src/layouts/Docs.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const { prev, next } = getPrevNext(currentPath);
3030

3131
<!-- Main content -->
3232
<main id="main-content" class="flex-1 min-w-0 py-8 px-2 sm:px-6 lg:px-10">
33-
<article class="prose max-w-3xl">
33+
<article class="prose max-w-3xl" data-pagefind-body>
3434
<slot />
3535
</article>
3636

site/src/pages/index.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import Footer from '../components/Footer.astro';
1212
---
1313
<Base title="Mantle — Headless AI Workflow Automation">
1414
<Nav isHome={true} />
15-
<main id="main-content" class="pb-16 md:pb-0">
15+
<main id="main-content" class="pb-16 md:pb-0" data-pagefind-ignore>
1616
<Hero />
1717
<Features />
1818
<HowItWorks />

site/src/styles/global.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,13 @@ body {
157157
opacity: 1;
158158
}
159159

160+
/* Pagefind search result highlights */
161+
mark[data-pagefind-highlight] {
162+
background-color: var(--color-primary);
163+
color: var(--color-on-primary);
164+
padding: 0 2px;
165+
}
166+
160167
/* Scrollbar styling */
161168
::-webkit-scrollbar {
162169
width: 8px;

0 commit comments

Comments
 (0)