Skip to content

Commit 5089655

Browse files
michaelmcneesclaudecoderabbitai[bot]
authored
feat: CEL expression docs + docs search (closes #1, #5) (#6)
* fix: improve mobile navigation and layouts on docs site Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: replace mobile top nav with bottom navigation bar for better reachability Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add practical CEL expression reference with examples (closes #1) Replace the thin CEL overview with comprehensive documentation covering available variables, common expression patterns, template vs bare syntax, type safety, bracket notation, and limitations. Update links in data-passing and workflow-reference docs to point to cel.dev and the new expressions guide instead of the go-cel GitHub repo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 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> * fix: address all 9 Copilot review comments on PR #6 Search component: - Split into DocsSearch (trigger only) + SearchModal (single shared modal) - SearchModal rendered once in Base.astro, no duplicate IDs - Global Cmd+K handler registered once with guard flag - Excerpt sanitized via DOMParser (only <mark> tags allowed) - Dialog a11y: role="dialog", aria-modal="true", aria-label Navigation: - isDocs derived from Astro.url.pathname.startsWith('/docs') - Nav/Sidebar overlay: use onclick assignment, no cloneNode (fixes detached ref) Content: - data-pagefind-ignore wraps entire landing page (Nav + Footer excluded too) - Removed unused .table-wrapper CSS, applied scroll directly to .prose table Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address 5 Copilot comments on PR #6 1. CEL link: separate external cel.dev link from internal expressions guide 2. status_code → status in server-guide (matches http.go output) 3. sanitizeExcerpt: recursive DOM walk preserves nested <mark> tags 4. Search race condition: requestId counter discards stale responses 5. Bottom padding: moved to global CSS @media (max-width: 1023px) body so all pages (including 404) clear the mobile bottom nav Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address reviewer comments — input type, deprecated CSS - expressions.md: type: integer → type: number (matches valid input types) - global.css: word-break: break-word → overflow-wrap: anywhere (deprecated) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: search modal focus trap, restore focus, invalidate on close - Focus trap: Tab cycles within modal when open (first/last wrapping) - Restore focus: previously focused trigger gets focus back on close - Invalidate requests: searchRequestId incremented on open AND close, preventing stale responses from populating after modal reset Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update site/src/components/DocsSidebar.astro Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: address remaining review comments on PR #6 Verified each finding against current code — 4 needed fixes, 4 already done: Fixed: 1. DocsSearch: cross-platform kbd (⌘K on Mac, Ctrl+K on Windows/Linux) 2. DocsSidebar: GitHub link target="_blank" rel="noopener" 3. Nav: aria-label on bottom nav, aria-controls + aria-expanded on menu button, synced with toggle handler 4. server-guide: bracket notation for steps['check-api'] on line 74 Already fixed (verified): - SearchModal focus trap (previousFocus, Tab cycling) ✓ - SearchModal requestId invalidation on open/close ✓ - expressions.md type: number (not integer) ✓ - CSS overflow-wrap: anywhere (not word-break: break-word) ✓ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: sync initial aria-expanded/aria-hidden state in initNav --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 613b506 commit 5089655

10 files changed

Lines changed: 536 additions & 62 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: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
// This component renders only a trigger button.
3+
// The search modal is rendered once in Base.astro via SearchModal.
4+
---
5+
<button
6+
data-search-trigger
7+
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"
8+
type="button"
9+
aria-label="Search documentation"
10+
>
11+
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
12+
<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" />
13+
</svg>
14+
<span class="flex-1 text-left">Search docs...</span>
15+
<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>
16+
</button>
17+
<script is:inline>
18+
(function(){
19+
if (window.__shortcutDetected) return;
20+
window.__shortcutDetected = true;
21+
var isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');
22+
if (!isMac) {
23+
document.querySelectorAll('.shortcut-mac').forEach(function(e){ e.classList.add('hidden'); });
24+
document.querySelectorAll('.shortcut-other').forEach(function(e){ e.classList.remove('hidden'); });
25+
}
26+
})();
27+
</script>
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
---
2+
// Single search modal rendered once in Base.astro.
3+
// Multiple DocsSearch trigger buttons open this shared modal.
4+
---
5+
<div id="search-modal" class="fixed inset-0 z-[100] hidden" role="dialog" aria-modal="true" aria-label="Search documentation">
6+
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" data-search-backdrop></div>
7+
<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">
8+
<div class="flex items-center gap-3 px-4 py-3 border-b border-outline-variant">
9+
<svg class="w-4 h-4 text-on-surface-variant 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+
<input
13+
data-search-input
14+
type="text"
15+
placeholder="Search documentation..."
16+
class="flex-1 bg-transparent text-on-surface text-sm outline-none placeholder:text-on-surface-variant/50 font-body"
17+
autocomplete="off"
18+
/>
19+
<kbd class="text-[10px] text-on-surface-variant/60 border border-outline-variant/50 px-1.5 py-0.5 rounded-sm">Esc</kbd>
20+
</div>
21+
<div data-search-results class="overflow-y-auto p-2 text-sm">
22+
<p class="px-3 py-4 text-on-surface-variant/60 text-center text-xs">Type to search...</p>
23+
</div>
24+
</div>
25+
</div>
26+
27+
<script is:inline>
28+
(function() {
29+
if (window.__searchInitialized) return;
30+
window.__searchInitialized = true;
31+
32+
var pagefind = null;
33+
34+
function loadPagefind() {
35+
if (pagefind) return Promise.resolve(pagefind);
36+
return import('/pagefind/pagefind.js').then(function(pf) {
37+
pagefind = pf;
38+
return pf.init().then(function() { return pf; });
39+
}).catch(function() { pagefind = null; return null; });
40+
}
41+
42+
function getModal() { return document.getElementById('search-modal'); }
43+
function getInput() { return document.querySelector('[data-search-input]'); }
44+
function getResults() { return document.querySelector('[data-search-results]'); }
45+
46+
function setResultsText(msg) {
47+
var r = getResults();
48+
if (!r) return;
49+
while (r.firstChild) r.removeChild(r.firstChild);
50+
var p = document.createElement('p');
51+
p.className = 'px-3 py-4 text-on-surface-variant/60 text-center text-xs';
52+
p.textContent = msg;
53+
r.appendChild(p);
54+
}
55+
56+
var previousFocus = null;
57+
var searchRequestId = 0;
58+
59+
function openSearch() {
60+
var modal = getModal();
61+
var input = getInput();
62+
if (!modal || !input) return;
63+
previousFocus = document.activeElement;
64+
searchRequestId++; // invalidate any pending requests
65+
modal.classList.remove('hidden');
66+
input.value = '';
67+
input.focus();
68+
setResultsText('Type to search...');
69+
}
70+
71+
function closeSearch() {
72+
var modal = getModal();
73+
if (modal) modal.classList.add('hidden');
74+
searchRequestId++; // invalidate any pending requests
75+
if (previousFocus && previousFocus.focus) {
76+
previousFocus.focus();
77+
previousFocus = null;
78+
}
79+
}
80+
81+
// Trap focus inside the modal when open
82+
document.addEventListener('keydown', function(e) {
83+
if (e.key !== 'Tab') return;
84+
var modal = getModal();
85+
if (!modal || modal.classList.contains('hidden')) return;
86+
87+
var focusable = modal.querySelectorAll('input, a, button, [tabindex]:not([tabindex="-1"])');
88+
if (focusable.length === 0) return;
89+
90+
var first = focusable[0];
91+
var last = focusable[focusable.length - 1];
92+
93+
if (e.shiftKey) {
94+
if (document.activeElement === first) {
95+
e.preventDefault();
96+
last.focus();
97+
}
98+
} else {
99+
if (document.activeElement === last) {
100+
e.preventDefault();
101+
first.focus();
102+
}
103+
}
104+
});
105+
106+
// Recursively sanitize Pagefind excerpt: preserve only <mark> tags, strip everything else
107+
function sanitizeExcerpt(html) {
108+
var parser = new DOMParser();
109+
var doc = parser.parseFromString(html, 'text/html');
110+
var container = document.createElement('div');
111+
function walk(parent, target) {
112+
for (var i = 0; i < parent.childNodes.length; i++) {
113+
var node = parent.childNodes[i];
114+
if (node.nodeType === 3) {
115+
target.appendChild(document.createTextNode(node.textContent));
116+
} else if (node.nodeType === 1 && node.tagName === 'MARK') {
117+
var mark = document.createElement('mark');
118+
mark.setAttribute('data-pagefind-highlight', '');
119+
walk(node, mark);
120+
target.appendChild(mark);
121+
} else if (node.nodeType === 1) {
122+
walk(node, target);
123+
}
124+
}
125+
}
126+
walk(doc.body, container);
127+
return container;
128+
}
129+
130+
document.addEventListener('keydown', function(e) {
131+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
132+
e.preventDefault();
133+
var modal = getModal();
134+
if (!modal) return;
135+
if (modal.classList.contains('hidden')) openSearch();
136+
else closeSearch();
137+
}
138+
if (e.key === 'Escape') closeSearch();
139+
});
140+
141+
document.addEventListener('click', function(e) {
142+
if (e.target && e.target.hasAttribute('data-search-backdrop')) closeSearch();
143+
if (e.target && e.target.closest('[data-search-trigger]')) openSearch();
144+
});
145+
146+
var debounceTimer;
147+
document.addEventListener('input', function(e) {
148+
if (!e.target || !e.target.hasAttribute('data-search-input')) return;
149+
clearTimeout(debounceTimer);
150+
debounceTimer = setTimeout(function() {
151+
var input = e.target;
152+
var results = getResults();
153+
if (!results) return;
154+
155+
var query = input.value.trim();
156+
if (!query) {
157+
setResultsText('Type to search...');
158+
return;
159+
}
160+
161+
var thisRequestId = ++searchRequestId;
162+
163+
loadPagefind().then(function(pf) {
164+
if (!pf) {
165+
setResultsText('Search unavailable (build required)');
166+
return;
167+
}
168+
169+
pf.search(query).then(function(search) {
170+
if (thisRequestId !== searchRequestId) return; // stale response
171+
if (search.results.length === 0) {
172+
setResultsText('No results for "' + query + '"');
173+
return;
174+
}
175+
176+
Promise.all(search.results.slice(0, 8).map(function(r) { return r.data(); }))
177+
.then(function(items) {
178+
if (thisRequestId !== searchRequestId) return; // stale response
179+
while (results.firstChild) results.removeChild(results.firstChild);
180+
items.forEach(function(item) {
181+
var link = document.createElement('a');
182+
link.href = item.url;
183+
link.className = 'block px-3 py-2 hover:bg-surface-container-high transition-colors group';
184+
185+
var title = document.createElement('div');
186+
title.className = 'font-medium text-on-surface text-sm group-hover:text-primary transition-colors';
187+
title.textContent = (item.meta && item.meta.title) || item.url;
188+
link.appendChild(title);
189+
190+
if (item.excerpt) {
191+
var excerptEl = sanitizeExcerpt(item.excerpt);
192+
excerptEl.className = 'text-xs text-on-surface-variant mt-0.5 line-clamp-2';
193+
link.appendChild(excerptEl);
194+
}
195+
196+
link.addEventListener('click', closeSearch);
197+
results.appendChild(link);
198+
});
199+
});
200+
});
201+
});
202+
}, 200);
203+
});
204+
})();
205+
</script>

0 commit comments

Comments
 (0)