@cleocode/cleo 2026.5.84 → 2026.5.87

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.
@@ -0,0 +1,28 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>CLEO Docs Viewer</title>
7
+ <link rel="stylesheet" href="/viewer/styles.css" />
8
+ </head>
9
+ <body>
10
+ <header class="topbar">
11
+ <h1>CLEO Docs</h1>
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" />
14
+ </header>
15
+ <main class="layout">
16
+ <aside id="sidebar">
17
+ <div id="search-results" aria-live="polite" hidden></div>
18
+ <div id="doc-list" aria-live="polite">Loading docs...</div>
19
+ </aside>
20
+ <section id="content">
21
+ <article id="doc-render">
22
+ <p class="hint">Select a document from the sidebar, or open a direct link like <code>/docs/&lt;slug&gt;</code>.</p>
23
+ </article>
24
+ </section>
25
+ </main>
26
+ <script src="/viewer/viewer.js" type="module"></script>
27
+ </body>
28
+ </html>
@@ -0,0 +1,196 @@
1
+ :root {
2
+ --bg: #0f1115;
3
+ --surface: #161a22;
4
+ --surface-2: #1f2632;
5
+ --border: #2a3140;
6
+ --text: #d6dbe3;
7
+ --text-muted: #8a93a3;
8
+ --accent: #6aa7ff;
9
+ --accent-hover: #88baff;
10
+ --code-bg: #0b0d12;
11
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
12
+ }
13
+
14
+ * { box-sizing: border-box; }
15
+
16
+ html, body {
17
+ margin: 0;
18
+ padding: 0;
19
+ background: var(--bg);
20
+ color: var(--text);
21
+ height: 100%;
22
+ }
23
+
24
+ .topbar {
25
+ display: flex;
26
+ align-items: center;
27
+ gap: 1rem;
28
+ padding: 0.75rem 1.25rem;
29
+ background: var(--surface);
30
+ border-bottom: 1px solid var(--border);
31
+ position: sticky;
32
+ top: 0;
33
+ z-index: 10;
34
+ }
35
+
36
+ .topbar h1 {
37
+ margin: 0;
38
+ font-size: 1rem;
39
+ letter-spacing: 0.04em;
40
+ color: var(--accent);
41
+ }
42
+
43
+ #filter {
44
+ flex: 1;
45
+ max-width: 480px;
46
+ background: var(--surface-2);
47
+ border: 1px solid var(--border);
48
+ color: var(--text);
49
+ padding: 0.45rem 0.75rem;
50
+ border-radius: 6px;
51
+ font: inherit;
52
+ }
53
+
54
+ #filter:focus { outline: 2px solid var(--accent); outline-offset: 0; }
55
+
56
+ .layout {
57
+ display: grid;
58
+ grid-template-columns: 280px 1fr;
59
+ min-height: calc(100vh - 56px);
60
+ }
61
+
62
+ #sidebar {
63
+ border-right: 1px solid var(--border);
64
+ background: var(--surface);
65
+ overflow-y: auto;
66
+ padding: 0.5rem 0;
67
+ max-height: calc(100vh - 56px);
68
+ position: sticky;
69
+ top: 56px;
70
+ }
71
+
72
+ #doc-list a {
73
+ display: block;
74
+ padding: 0.5rem 1rem;
75
+ color: var(--text);
76
+ text-decoration: none;
77
+ border-left: 3px solid transparent;
78
+ font-size: 0.9rem;
79
+ line-height: 1.3;
80
+ }
81
+
82
+ #doc-list a small {
83
+ display: block;
84
+ color: var(--text-muted);
85
+ font-size: 0.75rem;
86
+ margin-top: 2px;
87
+ }
88
+
89
+ #doc-list a:hover { background: var(--surface-2); }
90
+ #doc-list a.active {
91
+ background: var(--surface-2);
92
+ border-left-color: var(--accent);
93
+ }
94
+
95
+ #content { padding: 2rem 2.5rem; max-width: 60rem; }
96
+
97
+ #doc-render { line-height: 1.55; }
98
+ #doc-render h1 { border-bottom: 1px solid var(--border); padding-bottom: 0.35rem; }
99
+ #doc-render h1, #doc-render h2, #doc-render h3 { color: #fff; }
100
+ #doc-render a { color: var(--accent); }
101
+ #doc-render a:hover { color: var(--accent-hover); }
102
+ #doc-render code {
103
+ background: var(--code-bg);
104
+ padding: 0.1rem 0.35rem;
105
+ border-radius: 4px;
106
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
107
+ font-size: 0.9em;
108
+ }
109
+ #doc-render pre {
110
+ background: var(--code-bg);
111
+ padding: 0.85rem 1rem;
112
+ border-radius: 6px;
113
+ overflow-x: auto;
114
+ border: 1px solid var(--border);
115
+ }
116
+ #doc-render pre code { background: transparent; padding: 0; }
117
+ #doc-render blockquote {
118
+ margin: 0;
119
+ padding: 0.4rem 1rem;
120
+ border-left: 3px solid var(--accent);
121
+ color: var(--text-muted);
122
+ background: var(--surface);
123
+ }
124
+ #doc-render table { border-collapse: collapse; margin: 1rem 0; }
125
+ #doc-render th, #doc-render td {
126
+ border: 1px solid var(--border);
127
+ padding: 0.4rem 0.75rem;
128
+ }
129
+ #doc-render th { background: var(--surface-2); }
130
+
131
+ .hint { color: var(--text-muted); }
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
+ }
@@ -0,0 +1,397 @@
1
+ // CLEO Docs Viewer SPA — minimal, zero-dep client for /api/docs + /api/docs/:slug.
2
+ // Renders markdown via a tiny inline renderer (sufficient for headings, code,
3
+ // lists, links, blockquotes, inline-code, bold, italic, tables, hr). For
4
+ // anything richer we recommend wiring a real markdown lib later.
5
+
6
+ const $list = document.getElementById('doc-list');
7
+ const $render = document.getElementById('doc-render');
8
+ const $filter = document.getElementById('filter');
9
+ const $search = document.getElementById('search');
10
+ const $searchResults = document.getElementById('search-results');
11
+
12
+ const state = {
13
+ docs: [],
14
+ activeSlug: null,
15
+ searchSeq: 0,
16
+ };
17
+
18
+ function escapeHtml(str) {
19
+ return String(str)
20
+ .replace(/&/g, '&amp;')
21
+ .replace(/</g, '&lt;')
22
+ .replace(/>/g, '&gt;')
23
+ .replace(/"/g, '&quot;')
24
+ .replace(/'/g, '&#39;');
25
+ }
26
+
27
+ // Tiny markdown -> HTML renderer. Not CommonMark-complete; covers the basics.
28
+ function renderMarkdown(md) {
29
+ if (typeof md !== 'string') return '';
30
+ const lines = md.split('\n');
31
+ const out = [];
32
+ let inCode = false;
33
+ let codeBuf = [];
34
+ let codeLang = '';
35
+ let listType = null;
36
+ let listBuf = [];
37
+ let paraBuf = [];
38
+ let inTable = false;
39
+ let tableBuf = [];
40
+
41
+ const flushPara = () => {
42
+ if (paraBuf.length === 0) return;
43
+ out.push('<p>' + inlineMd(paraBuf.join(' ')) + '</p>');
44
+ paraBuf = [];
45
+ };
46
+ const flushList = () => {
47
+ if (listType === null || listBuf.length === 0) return;
48
+ const tag = listType === 'ol' ? 'ol' : 'ul';
49
+ out.push(`<${tag}>` + listBuf.map((i) => `<li>${inlineMd(i)}</li>`).join('') + `</${tag}>`);
50
+ listBuf = [];
51
+ listType = null;
52
+ };
53
+ const flushTable = () => {
54
+ if (!inTable || tableBuf.length === 0) return;
55
+ const rows = tableBuf.map((r) =>
56
+ r
57
+ .replace(/^\||\|$/g, '')
58
+ .split('|')
59
+ .map((c) => c.trim()),
60
+ );
61
+ const head = rows[0];
62
+ const body = rows.slice(2);
63
+ let html = '<table><thead><tr>';
64
+ for (const c of head) html += `<th>${inlineMd(c)}</th>`;
65
+ html += '</tr></thead><tbody>';
66
+ for (const row of body) {
67
+ html += '<tr>';
68
+ for (const c of row) html += `<td>${inlineMd(c)}</td>`;
69
+ html += '</tr>';
70
+ }
71
+ html += '</tbody></table>';
72
+ out.push(html);
73
+ tableBuf = [];
74
+ inTable = false;
75
+ };
76
+
77
+ for (let i = 0; i < lines.length; i++) {
78
+ const line = lines[i];
79
+ const fence = line.match(/^```(\w*)\s*$/);
80
+ if (fence) {
81
+ flushPara();
82
+ flushList();
83
+ flushTable();
84
+ if (inCode) {
85
+ out.push(
86
+ `<pre><code class="lang-${escapeHtml(codeLang)}">` +
87
+ escapeHtml(codeBuf.join('\n')) +
88
+ '</code></pre>',
89
+ );
90
+ codeBuf = [];
91
+ codeLang = '';
92
+ inCode = false;
93
+ } else {
94
+ inCode = true;
95
+ codeLang = fence[1] || '';
96
+ }
97
+ continue;
98
+ }
99
+ if (inCode) {
100
+ codeBuf.push(line);
101
+ continue;
102
+ }
103
+
104
+ // Blank line → flush block buffers
105
+ if (/^\s*$/.test(line)) {
106
+ flushPara();
107
+ flushList();
108
+ flushTable();
109
+ continue;
110
+ }
111
+
112
+ // Tables (very basic — | a | b | with separator row)
113
+ if (/^\s*\|.*\|\s*$/.test(line)) {
114
+ if (!inTable) {
115
+ flushPara();
116
+ flushList();
117
+ inTable = true;
118
+ }
119
+ tableBuf.push(line.trim());
120
+ continue;
121
+ } else if (inTable) {
122
+ flushTable();
123
+ }
124
+
125
+ // Headings
126
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
127
+ if (h) {
128
+ flushPara();
129
+ flushList();
130
+ const level = h[1].length;
131
+ out.push(`<h${level}>${inlineMd(h[2])}</h${level}>`);
132
+ continue;
133
+ }
134
+
135
+ // HR
136
+ if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
137
+ flushPara();
138
+ flushList();
139
+ out.push('<hr/>');
140
+ continue;
141
+ }
142
+
143
+ // Blockquote
144
+ const bq = line.match(/^>\s?(.*)$/);
145
+ if (bq) {
146
+ flushPara();
147
+ flushList();
148
+ out.push(`<blockquote>${inlineMd(bq[1])}</blockquote>`);
149
+ continue;
150
+ }
151
+
152
+ // Ordered / unordered list items
153
+ const ol = line.match(/^\s*(\d+)\.\s+(.*)$/);
154
+ const ul = line.match(/^\s*[-*+]\s+(.*)$/);
155
+ if (ol) {
156
+ flushPara();
157
+ if (listType !== 'ol') flushList();
158
+ listType = 'ol';
159
+ listBuf.push(ol[2]);
160
+ continue;
161
+ }
162
+ if (ul) {
163
+ flushPara();
164
+ if (listType !== 'ul') flushList();
165
+ listType = 'ul';
166
+ listBuf.push(ul[1]);
167
+ continue;
168
+ }
169
+
170
+ // Default — accumulate paragraph
171
+ flushList();
172
+ paraBuf.push(line);
173
+ }
174
+
175
+ if (inCode) {
176
+ out.push('<pre><code>' + escapeHtml(codeBuf.join('\n')) + '</code></pre>');
177
+ }
178
+ flushPara();
179
+ flushList();
180
+ flushTable();
181
+
182
+ return out.join('\n');
183
+ }
184
+
185
+ // Inline markdown — code spans, bold, italic, links.
186
+ function inlineMd(s) {
187
+ let str = escapeHtml(s);
188
+ // Inline code
189
+ str = str.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`);
190
+ // Bold + italic combined
191
+ str = str.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>');
192
+ // Bold
193
+ str = str.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
194
+ // Italic
195
+ str = str.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
196
+ // Links [text](href)
197
+ str = str.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, href) => {
198
+ const safe = String(href).replace(/"/g, '%22');
199
+ return `<a href="${safe}" target="_blank" rel="noopener">${text}</a>`;
200
+ });
201
+ return str;
202
+ }
203
+
204
+ async function fetchJson(url) {
205
+ const res = await fetch(url, { headers: { Accept: 'application/json' } });
206
+ const body = await res.json().catch(() => null);
207
+ if (!res.ok || !body) {
208
+ const code = body?.error?.code ?? 'E_HTTP';
209
+ const msg = body?.error?.message ?? res.statusText;
210
+ throw new Error(`${code}: ${msg}`);
211
+ }
212
+ if (body && body.success === false) {
213
+ throw new Error(`${body.error?.code ?? 'E_UNKNOWN'}: ${body.error?.message ?? 'unknown'}`);
214
+ }
215
+ return body.data;
216
+ }
217
+
218
+ function renderDocList(docs) {
219
+ if (docs.length === 0) {
220
+ $list.innerHTML = '<p class="hint">No published docs in this project. Run <code>cleo docs publish</code> first.</p>';
221
+ return;
222
+ }
223
+ const html = docs
224
+ .map((d) => {
225
+ const slug = escapeHtml(d.slug || d.id);
226
+ const title = escapeHtml(d.title || d.slug || d.id);
227
+ const type = d.type ? `<small>${escapeHtml(d.type)}</small>` : '';
228
+ const isActive = d.slug === state.activeSlug ? ' class="active"' : '';
229
+ return `<a href="/docs/${slug}" data-slug="${slug}"${isActive}>${title}${type}</a>`;
230
+ })
231
+ .join('');
232
+ $list.innerHTML = html;
233
+ $list.querySelectorAll('a').forEach((a) => {
234
+ a.addEventListener('click', (e) => {
235
+ e.preventDefault();
236
+ const slug = a.getAttribute('data-slug');
237
+ navigate(slug);
238
+ });
239
+ });
240
+ }
241
+
242
+ function applyFilter(q) {
243
+ const lower = q.toLowerCase().trim();
244
+ if (!lower) return renderDocList(state.docs);
245
+ const filtered = state.docs.filter((d) => {
246
+ return (
247
+ (d.slug || '').toLowerCase().includes(lower) ||
248
+ (d.title || '').toLowerCase().includes(lower) ||
249
+ (d.type || '').toLowerCase().includes(lower)
250
+ );
251
+ });
252
+ renderDocList(filtered);
253
+ }
254
+
255
+ async function loadDoc(slug) {
256
+ if (!slug) {
257
+ $render.innerHTML = '<p class="hint">Select a document from the sidebar.</p>';
258
+ return;
259
+ }
260
+ state.activeSlug = slug;
261
+ $render.innerHTML = '<p class="hint">Loading...</p>';
262
+ try {
263
+ const data = await fetchJson(`/api/docs/${encodeURIComponent(slug)}`);
264
+ const md = data.content ?? '';
265
+ $render.innerHTML = renderMarkdown(md);
266
+ document.title = `${data.title || slug} — CLEO Docs`;
267
+ // Highlight active in sidebar
268
+ $list.querySelectorAll('a').forEach((a) => {
269
+ a.classList.toggle('active', a.getAttribute('data-slug') === slug);
270
+ });
271
+ } catch (err) {
272
+ $render.innerHTML = `<p class="error">${escapeHtml(String(err.message || err))}</p>`;
273
+ }
274
+ }
275
+
276
+ function navigate(slug) {
277
+ history.pushState({ slug }, '', `/docs/${slug}`);
278
+ loadDoc(slug);
279
+ }
280
+
281
+ window.addEventListener('popstate', (e) => {
282
+ const slug = e.state?.slug || slugFromPath(location.pathname);
283
+ if (slug) loadDoc(slug);
284
+ });
285
+
286
+ function slugFromPath(pathname) {
287
+ const m = pathname.match(/^\/docs\/([^/?#]+)/);
288
+ return m ? decodeURIComponent(m[1]) : null;
289
+ }
290
+
291
+ $filter.addEventListener('input', (e) => applyFilter(e.target.value));
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
+
387
+ (async function init() {
388
+ try {
389
+ const data = await fetchJson('/api/docs');
390
+ state.docs = data.docs || [];
391
+ renderDocList(state.docs);
392
+ const initialSlug = slugFromPath(location.pathname);
393
+ if (initialSlug) loadDoc(initialSlug);
394
+ } catch (err) {
395
+ $list.innerHTML = `<p class="error">${escapeHtml(String(err.message || err))}</p>`;
396
+ }
397
+ })();