@cleocode/cleo 2026.5.86 → 2026.5.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,9 +10,11 @@
10
10
  <header class="topbar">
11
11
  <h1>CLEO Docs</h1>
12
12
  <input id="filter" type="search" placeholder="Filter by slug, title, type..." autocomplete="off" />
13
+ <input id="search" type="search" placeholder="Search content (ranked)..." autocomplete="off" aria-label="Search docs by content" />
13
14
  </header>
14
15
  <main class="layout">
15
16
  <aside id="sidebar">
17
+ <div id="search-results" aria-live="polite" hidden></div>
16
18
  <div id="doc-list" aria-live="polite">Loading docs...</div>
17
19
  </aside>
18
20
  <section id="content">
@@ -130,3 +130,67 @@ html, body {
130
130
 
131
131
  .hint { color: var(--text-muted); }
132
132
  .error { color: #ff7f7f; }
133
+
134
+ /* T9647 — search results UI */
135
+ #search {
136
+ flex: 1;
137
+ max-width: 360px;
138
+ background: var(--surface-2);
139
+ border: 1px solid var(--border);
140
+ color: var(--text);
141
+ padding: 0.45rem 0.75rem;
142
+ border-radius: 6px;
143
+ font: inherit;
144
+ }
145
+ #search:focus { outline: 2px solid var(--accent); outline-offset: 0; }
146
+
147
+ #search-results {
148
+ padding: 0.25rem 0;
149
+ border-bottom: 1px solid var(--border);
150
+ }
151
+ #search-results .result {
152
+ display: block;
153
+ padding: 0.55rem 1rem;
154
+ color: var(--text);
155
+ text-decoration: none;
156
+ border-left: 3px solid transparent;
157
+ }
158
+ #search-results .result:hover {
159
+ background: var(--surface-2);
160
+ border-left-color: var(--accent);
161
+ }
162
+ #search-results .result-head {
163
+ display: flex;
164
+ align-items: baseline;
165
+ gap: 0.5rem;
166
+ font-size: 0.9rem;
167
+ }
168
+ #search-results .result-name {
169
+ font-weight: 600;
170
+ color: var(--accent);
171
+ flex: 1;
172
+ word-break: break-word;
173
+ }
174
+ #search-results .result-type {
175
+ font-size: 0.7rem;
176
+ color: var(--text-muted);
177
+ text-transform: uppercase;
178
+ letter-spacing: 0.03em;
179
+ }
180
+ #search-results .result-score {
181
+ font-size: 0.75rem;
182
+ color: var(--text-muted);
183
+ font-variant-numeric: tabular-nums;
184
+ }
185
+ #search-results .result-snippet {
186
+ color: var(--text-muted);
187
+ font-size: 0.8rem;
188
+ line-height: 1.4;
189
+ margin-top: 0.2rem;
190
+ }
191
+ #search-results mark {
192
+ background: rgba(106, 167, 255, 0.25);
193
+ color: inherit;
194
+ padding: 0 2px;
195
+ border-radius: 2px;
196
+ }
@@ -6,10 +6,13 @@
6
6
  const $list = document.getElementById('doc-list');
7
7
  const $render = document.getElementById('doc-render');
8
8
  const $filter = document.getElementById('filter');
9
+ const $search = document.getElementById('search');
10
+ const $searchResults = document.getElementById('search-results');
9
11
 
10
12
  const state = {
11
13
  docs: [],
12
14
  activeSlug: null,
15
+ searchSeq: 0,
13
16
  };
14
17
 
15
18
  function escapeHtml(str) {
@@ -287,6 +290,100 @@ function slugFromPath(pathname) {
287
290
 
288
291
  $filter.addEventListener('input', (e) => applyFilter(e.target.value));
289
292
 
293
+ // ── Search (T9647) — debounced /api/search with ranked results ──────────────
294
+
295
+ function debounce(fn, ms) {
296
+ let timer = null;
297
+ return function debounced(...args) {
298
+ if (timer !== null) clearTimeout(timer);
299
+ timer = setTimeout(() => {
300
+ timer = null;
301
+ fn.apply(this, args);
302
+ }, ms);
303
+ };
304
+ }
305
+
306
+ function highlightMatches(snippet, query) {
307
+ if (!query) return escapeHtml(snippet);
308
+ const terms = query
309
+ .toLowerCase()
310
+ .split(/\s+/)
311
+ .filter((t) => t.length >= 2);
312
+ if (terms.length === 0) return escapeHtml(snippet);
313
+ // Build a regex that matches any term, case-insensitive. Escape regex metachars.
314
+ const pattern = terms
315
+ .map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
316
+ .join('|');
317
+ const re = new RegExp(`(${pattern})`, 'gi');
318
+ const escaped = escapeHtml(snippet);
319
+ return escaped.replace(re, '<mark>$1</mark>');
320
+ }
321
+
322
+ function renderSearchResults(query, payload) {
323
+ if (!payload || !payload.hits || payload.hits.length === 0) {
324
+ $searchResults.innerHTML =
325
+ `<p class="hint">No results for <code>${escapeHtml(query)}</code>.</p>`;
326
+ $searchResults.hidden = false;
327
+ $list.hidden = true;
328
+ return;
329
+ }
330
+ const html = payload.hits
331
+ .map((h) => {
332
+ const slug = escapeHtml(h.slug || h.id);
333
+ const name = escapeHtml(h.name || h.slug || h.id);
334
+ const type = h.type ? `<span class="result-type">${escapeHtml(h.type)}</span>` : '';
335
+ const score = typeof h.score === 'number' ? h.score.toFixed(3) : '—';
336
+ const snippet = highlightMatches(h.snippet || '', query);
337
+ return [
338
+ `<a href="/docs/${slug}" data-slug="${slug}" class="result">`,
339
+ `<div class="result-head"><span class="result-name">${name}</span>${type}<span class="result-score">${score}</span></div>`,
340
+ `<div class="result-snippet">${snippet}</div>`,
341
+ `</a>`,
342
+ ].join('');
343
+ })
344
+ .join('');
345
+ $searchResults.innerHTML = html;
346
+ $searchResults.hidden = false;
347
+ $list.hidden = true;
348
+ $searchResults.querySelectorAll('a.result').forEach((a) => {
349
+ a.addEventListener('click', (e) => {
350
+ e.preventDefault();
351
+ const slug = a.getAttribute('data-slug');
352
+ navigate(slug);
353
+ });
354
+ });
355
+ }
356
+
357
+ function clearSearchResults() {
358
+ $searchResults.hidden = true;
359
+ $searchResults.innerHTML = '';
360
+ $list.hidden = false;
361
+ }
362
+
363
+ async function runSearch(query) {
364
+ const q = query.trim();
365
+ if (q.length === 0) {
366
+ clearSearchResults();
367
+ return;
368
+ }
369
+ const seq = ++state.searchSeq;
370
+ try {
371
+ const url = `/api/search?q=${encodeURIComponent(q)}&limit=20`;
372
+ const data = await fetchJson(url);
373
+ // Drop stale responses if a newer query has fired.
374
+ if (seq !== state.searchSeq) return;
375
+ renderSearchResults(q, data);
376
+ } catch (err) {
377
+ if (seq !== state.searchSeq) return;
378
+ $searchResults.innerHTML = `<p class="error">${escapeHtml(String(err.message || err))}</p>`;
379
+ $searchResults.hidden = false;
380
+ $list.hidden = true;
381
+ }
382
+ }
383
+
384
+ const debouncedSearch = debounce(runSearch, 300);
385
+ $search.addEventListener('input', (e) => debouncedSearch(e.target.value));
386
+
290
387
  (async function init() {
291
388
  try {
292
389
  const data = await fetchJson('/api/docs');