Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions site/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"build": "astro build && npx pagefind --site dist",
"preview": "astro preview"
},
"dependencies": {
"@astrojs/mdx": "^5.0.2",
"@astrojs/sitemap": "^3.7.1",
"@tailwindcss/vite": "^4.2.2",
"astro": "^6.0.8",
"pagefind": "^1.4.0",
"tailwindcss": "^4.2.2"
}
}
27 changes: 27 additions & 0 deletions site/src/components/DocsSearch.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
// This component renders only a trigger button.
// The search modal is rendered once in Base.astro via SearchModal.
---
<button
data-search-trigger
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"
type="button"
aria-label="Search documentation"
>
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
</svg>
<span class="flex-1 text-left">Search docs...</span>
<kbd class="hidden lg:inline text-[10px] text-on-surface-variant/60 border border-outline-variant/50 px-1 rounded-sm"><span class="shortcut-mac">⌘K</span><span class="shortcut-other hidden">Ctrl+K</span></kbd>
</button>
<script is:inline>
(function(){
if (window.__shortcutDetected) return;
window.__shortcutDetected = true;
var isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');
if (!isMac) {
document.querySelectorAll('.shortcut-mac').forEach(function(e){ e.classList.add('hidden'); });
document.querySelectorAll('.shortcut-other').forEach(function(e){ e.classList.remove('hidden'); });
}
})();
</script>
205 changes: 205 additions & 0 deletions site/src/components/SearchModal.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
---
// Single search modal rendered once in Base.astro.
// Multiple DocsSearch trigger buttons open this shared modal.
---
<div id="search-modal" class="fixed inset-0 z-[100] hidden" role="dialog" aria-modal="true" aria-label="Search documentation">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" data-search-backdrop></div>
<div class="fixed top-[15%] left-1/2 -translate-x-1/2 w-[calc(100%-2rem)] max-w-xl bg-surface-container border border-outline-variant shadow-2xl max-h-[60vh] flex flex-col">
<div class="flex items-center gap-3 px-4 py-3 border-b border-outline-variant">
<svg class="w-4 h-4 text-on-surface-variant shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
</svg>
<input
data-search-input
type="text"
placeholder="Search documentation..."
class="flex-1 bg-transparent text-on-surface text-sm outline-none placeholder:text-on-surface-variant/50 font-body"
autocomplete="off"
/>
<kbd class="text-[10px] text-on-surface-variant/60 border border-outline-variant/50 px-1.5 py-0.5 rounded-sm">Esc</kbd>
</div>
<div data-search-results class="overflow-y-auto p-2 text-sm">
<p class="px-3 py-4 text-on-surface-variant/60 text-center text-xs">Type to search...</p>
</div>
</div>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<script is:inline>
(function() {
if (window.__searchInitialized) return;
window.__searchInitialized = true;

var pagefind = null;

function loadPagefind() {
if (pagefind) return Promise.resolve(pagefind);
return import('/pagefind/pagefind.js').then(function(pf) {
pagefind = pf;
return pf.init().then(function() { return pf; });
}).catch(function() { pagefind = null; return null; });
}

function getModal() { return document.getElementById('search-modal'); }
function getInput() { return document.querySelector('[data-search-input]'); }
function getResults() { return document.querySelector('[data-search-results]'); }

function setResultsText(msg) {
var r = getResults();
if (!r) return;
while (r.firstChild) r.removeChild(r.firstChild);
var p = document.createElement('p');
p.className = 'px-3 py-4 text-on-surface-variant/60 text-center text-xs';
p.textContent = msg;
r.appendChild(p);
}

var previousFocus = null;
var searchRequestId = 0;

function openSearch() {
var modal = getModal();
var input = getInput();
if (!modal || !input) return;
previousFocus = document.activeElement;
searchRequestId++; // invalidate any pending requests
modal.classList.remove('hidden');
input.value = '';
input.focus();
setResultsText('Type to search...');
}

function closeSearch() {
var modal = getModal();
if (modal) modal.classList.add('hidden');
searchRequestId++; // invalidate any pending requests
if (previousFocus && previousFocus.focus) {
previousFocus.focus();
previousFocus = null;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Trap focus inside the modal when open
document.addEventListener('keydown', function(e) {
if (e.key !== 'Tab') return;
var modal = getModal();
if (!modal || modal.classList.contains('hidden')) return;

var focusable = modal.querySelectorAll('input, a, button, [tabindex]:not([tabindex="-1"])');
if (focusable.length === 0) return;

var first = focusable[0];
var last = focusable[focusable.length - 1];

if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});

// Recursively sanitize Pagefind excerpt: preserve only <mark> tags, strip everything else
function sanitizeExcerpt(html) {
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
var container = document.createElement('div');
function walk(parent, target) {
for (var i = 0; i < parent.childNodes.length; i++) {
var node = parent.childNodes[i];
if (node.nodeType === 3) {
target.appendChild(document.createTextNode(node.textContent));
} else if (node.nodeType === 1 && node.tagName === 'MARK') {
var mark = document.createElement('mark');
mark.setAttribute('data-pagefind-highlight', '');
walk(node, mark);
target.appendChild(mark);
} else if (node.nodeType === 1) {
walk(node, target);
}
}
}
walk(doc.body, container);
return container;
}

document.addEventListener('keydown', function(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
var modal = getModal();
if (!modal) return;
if (modal.classList.contains('hidden')) openSearch();
else closeSearch();
}
if (e.key === 'Escape') closeSearch();
});

document.addEventListener('click', function(e) {
if (e.target && e.target.hasAttribute('data-search-backdrop')) closeSearch();
if (e.target && e.target.closest('[data-search-trigger]')) openSearch();
});

var debounceTimer;
document.addEventListener('input', function(e) {
if (!e.target || !e.target.hasAttribute('data-search-input')) return;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
var input = e.target;
var results = getResults();
if (!results) return;

var query = input.value.trim();
if (!query) {
setResultsText('Type to search...');
return;
}

var thisRequestId = ++searchRequestId;

loadPagefind().then(function(pf) {
if (!pf) {
setResultsText('Search unavailable (build required)');
return;
}

pf.search(query).then(function(search) {
if (thisRequestId !== searchRequestId) return; // stale response
if (search.results.length === 0) {
setResultsText('No results for "' + query + '"');
return;
}

Promise.all(search.results.slice(0, 8).map(function(r) { return r.data(); }))
.then(function(items) {
if (thisRequestId !== searchRequestId) return; // stale response
while (results.firstChild) results.removeChild(results.firstChild);
items.forEach(function(item) {
var link = document.createElement('a');
link.href = item.url;
link.className = 'block px-3 py-2 hover:bg-surface-container-high transition-colors group';

var title = document.createElement('div');
title.className = 'font-medium text-on-surface text-sm group-hover:text-primary transition-colors';
title.textContent = (item.meta && item.meta.title) || item.url;
link.appendChild(title);

if (item.excerpt) {
var excerptEl = sanitizeExcerpt(item.excerpt);
excerptEl.className = 'text-xs text-on-surface-variant mt-0.5 line-clamp-2';
link.appendChild(excerptEl);
}

link.addEventListener('click', closeSearch);
results.appendChild(link);
});
});
});
});
Comment on lines +163 to +201

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Search results rendering can race: multiple pf.search(query) calls may be in-flight, and a slower response from an older query can overwrite the results for a newer query. Track a monotonically increasing request id (or compare against the latest input value) before updating the DOM so only the latest query updates the results list.

Copilot uses AI. Check for mistakes.
}, 200);
});
})();
</script>
Loading
Loading