@veluai/velu 0.2.13 → 0.2.15

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.
Files changed (109) hide show
  1. package/README.md +80 -80
  2. package/dist/cli.js +37 -37
  3. package/package.json +64 -64
  4. package/runtime/velu-ui/base.css +320 -320
  5. package/runtime/velu-ui/components/Accordion.jsx +64 -64
  6. package/runtime/velu-ui/components/ApiClient.jsx +207 -207
  7. package/runtime/velu-ui/components/ApiField.jsx +87 -87
  8. package/runtime/velu-ui/components/ApiPath.jsx +63 -63
  9. package/runtime/velu-ui/components/ApiReferencePage.jsx +384 -384
  10. package/runtime/velu-ui/components/ApiSamples.jsx +36 -36
  11. package/runtime/velu-ui/components/ApiSidebar.jsx +122 -122
  12. package/runtime/velu-ui/components/AskBar.jsx +71 -71
  13. package/runtime/velu-ui/components/Callout.jsx +114 -114
  14. package/runtime/velu-ui/components/Card.jsx +131 -131
  15. package/runtime/velu-ui/components/Chatbot.jsx +885 -885
  16. package/runtime/velu-ui/components/CodeBlock.jsx +375 -375
  17. package/runtime/velu-ui/components/Columns.jsx +56 -56
  18. package/runtime/velu-ui/components/ContextMenu.jsx +298 -273
  19. package/runtime/velu-ui/components/ErrorCard.jsx +138 -138
  20. package/runtime/velu-ui/components/Field.jsx +81 -81
  21. package/runtime/velu-ui/components/Image.jsx +163 -163
  22. package/runtime/velu-ui/components/Logo.jsx +31 -31
  23. package/runtime/velu-ui/components/MethodBadge.jsx +31 -31
  24. package/runtime/velu-ui/components/NavSelect.jsx +108 -108
  25. package/runtime/velu-ui/components/NotFound.jsx +63 -63
  26. package/runtime/velu-ui/components/PageFeedback.jsx +219 -219
  27. package/runtime/velu-ui/components/PageFooter.jsx +145 -145
  28. package/runtime/velu-ui/components/PageHeader.jsx +422 -422
  29. package/runtime/velu-ui/components/PageNav.jsx +77 -77
  30. package/runtime/velu-ui/components/PoweredBy.jsx +51 -51
  31. package/runtime/velu-ui/components/Prompt.jsx +115 -115
  32. package/runtime/velu-ui/components/Search.jsx +460 -460
  33. package/runtime/velu-ui/components/Sidebar.jsx +254 -254
  34. package/runtime/velu-ui/components/SocialLinks.jsx +90 -90
  35. package/runtime/velu-ui/components/Steps.jsx +65 -65
  36. package/runtime/velu-ui/components/ThemeToggle.jsx +48 -48
  37. package/runtime/velu-ui/components/Toc.jsx +537 -537
  38. package/runtime/velu-ui/components/TocBar.jsx +195 -195
  39. package/runtime/velu-ui/components/Tree.jsx +87 -87
  40. package/runtime/velu-ui/components/TryItBar.jsx +102 -102
  41. package/runtime/velu-ui/components/accordion.css +92 -92
  42. package/runtime/velu-ui/components/api-page.css +208 -208
  43. package/runtime/velu-ui/components/api.css +635 -635
  44. package/runtime/velu-ui/components/ask-bar.css +94 -94
  45. package/runtime/velu-ui/components/card.css +105 -105
  46. package/runtime/velu-ui/components/chatbot.css +622 -622
  47. package/runtime/velu-ui/components/code-block.css +263 -263
  48. package/runtime/velu-ui/components/context-menu.css +173 -173
  49. package/runtime/velu-ui/components/docs-layout.css +822 -822
  50. package/runtime/velu-ui/components/field.css +82 -82
  51. package/runtime/velu-ui/components/image.css +237 -237
  52. package/runtime/velu-ui/components/nav-select.css +157 -157
  53. package/runtime/velu-ui/components/not-found.css +94 -94
  54. package/runtime/velu-ui/components/page-feedback.css +241 -241
  55. package/runtime/velu-ui/components/page-footer.css +130 -130
  56. package/runtime/velu-ui/components/page-header.css +558 -558
  57. package/runtime/velu-ui/components/page-nav.css +50 -50
  58. package/runtime/velu-ui/components/powered-by.css +92 -92
  59. package/runtime/velu-ui/components/prompt.css +99 -99
  60. package/runtime/velu-ui/components/search.css +307 -307
  61. package/runtime/velu-ui/components/sidebar.css +205 -205
  62. package/runtime/velu-ui/components/steps.css +77 -77
  63. package/runtime/velu-ui/components/theme-toggle.css +102 -102
  64. package/runtime/velu-ui/components/toc-bar.css +234 -234
  65. package/runtime/velu-ui/components/tree.css +49 -49
  66. package/runtime/velu-ui/index.js +54 -54
  67. package/runtime/velu-ui/lib/api-send.js +92 -92
  68. package/runtime/velu-ui/lib/brand-icons.jsx +103 -103
  69. package/runtime/velu-ui/lib/component-schemas.js +100 -100
  70. package/runtime/velu-ui/lib/copyText.js +64 -64
  71. package/runtime/velu-ui/lib/docs-assistant.js +250 -250
  72. package/runtime/velu-ui/lib/lang-icons.jsx +147 -147
  73. package/runtime/velu-ui/lib/pagefind.js +113 -113
  74. package/runtime/velu-ui/lib/prism-langs.js +957 -957
  75. package/runtime/velu-ui/lib/prism-loader.js +74 -74
  76. package/runtime/velu-ui/lib/resolveIcon.jsx +29 -29
  77. package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -66
  78. package/runtime/velu-ui/mdx-components.jsx +105 -105
  79. package/runtime/velu-ui/primitives/Cluster.jsx +49 -49
  80. package/runtime/velu-ui/primitives/Stack.jsx +63 -63
  81. package/runtime/velu-ui/primitives/Switcher.jsx +57 -57
  82. package/runtime/velu-ui/primitives/stack.css +3 -3
  83. package/runtime/velu-ui/primitives/switcher.css +25 -25
  84. package/runtime/velu-ui/styles.css +46 -46
  85. package/runtime/velu-ui/tokens.css +4 -4
  86. package/schema/velu.schema.json +423 -423
  87. package/src/lib/extract-mdx-error.js +170 -170
  88. package/src/lib/issues.js +159 -159
  89. package/src/lib/known-components.js +34 -34
  90. package/src/navigation.js +443 -443
  91. package/src/runtime/App.jsx +1669 -1668
  92. package/src/runtime/ErrorBoundary.jsx +54 -54
  93. package/src/runtime/client-entry.jsx +22 -22
  94. package/src/runtime/server-entry.jsx +16 -16
  95. package/src/template.html +48 -48
  96. package/templates/starter/ai-tools/claude-code.mdx +26 -26
  97. package/templates/starter/ai-tools/cursor.mdx +17 -17
  98. package/templates/starter/api-reference/introduction.mdx +43 -43
  99. package/templates/starter/development.mdx +19 -19
  100. package/templates/starter/essentials/code.mdx +29 -29
  101. package/templates/starter/essentials/images.mdx +29 -29
  102. package/templates/starter/essentials/markdown.mdx +25 -25
  103. package/templates/starter/essentials/navigation.mdx +39 -39
  104. package/templates/starter/essentials/settings.mdx +30 -30
  105. package/templates/starter/favicon.svg +6 -6
  106. package/templates/starter/index.mdx +31 -31
  107. package/templates/starter/openapi.json +160 -160
  108. package/templates/starter/quickstart.mdx +31 -31
  109. package/templates/starter/velu.json +41 -41
@@ -1,460 +1,460 @@
1
- import React, {
2
- useState,
3
- useRef,
4
- useEffect,
5
- useMemo,
6
- useCallback,
7
- } from 'react';
8
- import resolveIcon from '../lib/resolveIcon.jsx';
9
-
10
- /**
11
- * Search — a Raycast-style command palette for the docs site. Ported
12
- * from the Claude-Design "Velu Search" handoff and retokenized onto
13
- * velu-ui's scale (themes for free via [data-theme]).
14
- *
15
- * <Search results={[…]} onSelect={(item) => …} />
16
- *
17
- * Renders a trigger box (drop it at the top of the page). Clicking it —
18
- * or pressing ⌘K / Ctrl+K anywhere — reveals the palette: a scrim plus
19
- * a centered panel with a search input and grouped, keyboard-navigable
20
- * results (↑/↓ to move, Enter to pick, Esc / scrim-click to close).
21
- *
22
- * @typedef {Object} SearchResult
23
- * @property {string} id
24
- * @property {string} group 'Recent Searches' | 'Suggested' | 'Pages'
25
- * @property {string[]} breadcrumb
26
- * @property {string} title
27
- * @property {'page'|'anchor'|'ext'} kind
28
- * @property {string} desc
29
- * @property {string} [href]
30
- *
31
- * @param {{ results?: SearchResult[], placeholder?: string,
32
- * onSelect?: (item: SearchResult) => void,
33
- * className?: string }} props
34
- */
35
-
36
- /* Sample result set — the demo data from the design. Override via the
37
- `results` prop with real indexed content. */
38
- const DEFAULT_RESULTS = [
39
- {
40
- id: 'auth-pw',
41
- group: 'Recent Searches',
42
- breadcrumb: ['Authentication setup', 'Password'],
43
- title: 'Password',
44
- kind: 'anchor',
45
- desc: 'Password authentication provides access control through credentials.',
46
- },
47
- {
48
- id: 'gh-sync',
49
- group: 'Recent Searches',
50
- breadcrumb: ['Get Started', 'GitHub & GitLab Sync'],
51
- title: 'Enabling GitHub sync',
52
- kind: 'page',
53
- desc: 'Connect a GitHub repository to keep your docs in sync on every push.',
54
- },
55
- {
56
- id: 'ai-search',
57
- group: 'Recent Searches',
58
- breadcrumb: ['Your Docs Site'],
59
- title: 'AI Search',
60
- kind: 'page',
61
- desc: 'Configure semantic search for your documentation site.',
62
- },
63
- {
64
- id: 'quickstart',
65
- group: 'Suggested',
66
- breadcrumb: ['Get Started'],
67
- title: 'Quickstart',
68
- kind: 'page',
69
- desc: 'Set up Velu and publish your first docs site in under 5 minutes.',
70
- },
71
- {
72
- id: 'overview',
73
- group: 'Suggested',
74
- breadcrumb: ['Get Started'],
75
- title: 'Overview',
76
- kind: 'page',
77
- desc: 'Velu is an AI-native documentation platform built for humans and AI agents.',
78
- },
79
- {
80
- id: 'site-settings',
81
- group: 'Suggested',
82
- breadcrumb: ['Your Docs Site'],
83
- title: 'Site Settings',
84
- kind: 'page',
85
- desc: 'Configure domain, theme, navigation and SEO for your docs site.',
86
- },
87
- {
88
- id: 'oauth',
89
- group: 'Pages',
90
- breadcrumb: ['Authentication setup', 'OAuth'],
91
- title: 'OAuth',
92
- kind: 'anchor',
93
- desc: 'Configure OAuth providers for sign-in.',
94
- },
95
- {
96
- id: 'saml',
97
- group: 'Pages',
98
- breadcrumb: ['Authentication setup', 'SAML'],
99
- title: 'SAML',
100
- kind: 'anchor',
101
- desc: 'Enterprise single sign-on via SAML 2.0.',
102
- },
103
- ];
104
-
105
- const GROUP_ORDER = ['Recent Searches', 'Suggested', 'Pages'];
106
-
107
- /* Left-hand glyph: '#' for an anchor, an icon for page / external. */
108
- function ResultGlyph({ kind }) {
109
- if (kind === 'anchor') {
110
- return <span className="velu-search__glyph-hash">#</span>;
111
- }
112
- const icon = kind === 'ext' ? 'external-link' : 'file-text';
113
- return (
114
- <span className="velu-search__glyph-icon">
115
- {resolveIcon(icon, { size: '1em' })}
116
- </span>
117
- );
118
- }
119
-
120
- function Breadcrumb({ items }) {
121
- return (
122
- <div className="velu-search__crumbs">
123
- {items.map((c, i) => (
124
- <React.Fragment key={i}>
125
- {i > 0 && <span className="velu-search__crumb-sep">›</span>}
126
- <span>{c}</span>
127
- </React.Fragment>
128
- ))}
129
- </div>
130
- );
131
- }
132
-
133
- function PaletteRow({ item, selected, navMode, onHover, onSelect }) {
134
- const ref = useRef(null);
135
- useEffect(() => {
136
- // Only auto-scroll for keyboard nav — on mouse hover the pointer is
137
- // already on the row, and scrolling there nudges the list (a 1px
138
- // jitter that flickers a line at the list edge).
139
- if (selected && navMode.current === 'keyboard' && ref.current) {
140
- ref.current.scrollIntoView({ block: 'nearest' });
141
- }
142
- }, [selected, navMode]);
143
- return (
144
- <div
145
- ref={ref}
146
- className={`velu-search__row${selected ? ' velu-search__row--selected' : ''}`}
147
- onMouseEnter={onHover}
148
- onClick={onSelect}
149
- role="option"
150
- aria-selected={selected}
151
- >
152
- <div className="velu-search__row-glyph">
153
- <ResultGlyph kind={item.kind} />
154
- </div>
155
- <Breadcrumb items={item.breadcrumb} />
156
- <div className="velu-search__row-title">{item.title}</div>
157
- <div className="velu-search__row-desc">{item.desc}</div>
158
- <div className="velu-search__row-meta">
159
- <span className="velu-search__kbd">↵</span>
160
- </div>
161
- </div>
162
- );
163
- }
164
-
165
- /* The revealed palette — scrim + centered panel. When `search` (an async
166
- query → results function, e.g. the Pagefind client) is provided, results
167
- come from it; otherwise the static `results` list is filtered in-memory. */
168
- function SearchPalette({ results, search, placeholder, onSelect, onClose }) {
169
- const [query, setQuery] = useState('');
170
- const [selected, setSelected] = useState(0);
171
- const [asyncResults, setAsyncResults] = useState([]);
172
- const [searching, setSearching] = useState(false);
173
- const inputRef = useRef(null);
174
- // 'keyboard' | 'mouse' — which input last moved the selection; gates
175
- // the rows' scrollIntoView so mouse hover doesn't jitter the list.
176
- const navMode = useRef('keyboard');
177
-
178
- useEffect(() => {
179
- inputRef.current?.focus();
180
- }, []);
181
-
182
- // Async source (Pagefind): debounce the query, fetch results.
183
- useEffect(() => {
184
- if (!search) return undefined;
185
- const q = query.trim();
186
- if (!q) {
187
- setAsyncResults([]);
188
- setSearching(false);
189
- return undefined;
190
- }
191
- let cancelled = false;
192
- setSearching(true);
193
- const t = setTimeout(async () => {
194
- const r = await search(q).catch(() => []);
195
- if (!cancelled) {
196
- setAsyncResults(Array.isArray(r) ? r : []);
197
- setSearching(false);
198
- }
199
- }, 150);
200
- return () => {
201
- cancelled = true;
202
- clearTimeout(t);
203
- };
204
- }, [query, search]);
205
-
206
- const filtered = useMemo(() => {
207
- if (search) return asyncResults;
208
- const q = query.trim().toLowerCase();
209
- if (!q) {
210
- // Empty query → just the recents.
211
- return results.filter((r) => r.group === 'Recent Searches');
212
- }
213
- return results.filter((r) => {
214
- const hay = `${r.title} ${r.breadcrumb.join(' ')} ${r.desc}`.toLowerCase();
215
- return hay.includes(q);
216
- });
217
- }, [search, asyncResults, query, results]);
218
-
219
- const grouped = useMemo(() => {
220
- const map = new Map();
221
- filtered.forEach((r) => {
222
- const g = r.group || 'Pages';
223
- if (!map.has(g)) map.set(g, []);
224
- map.get(g).push(r);
225
- });
226
- const known = GROUP_ORDER.filter((g) => map.has(g));
227
- const extra = [...map.keys()].filter((g) => !GROUP_ORDER.includes(g));
228
- return [...known, ...extra].map((g) => [g, map.get(g)]);
229
- }, [filtered]);
230
-
231
- const flat = useMemo(
232
- () => grouped.flatMap(([, items]) => items),
233
- [grouped],
234
- );
235
-
236
- useEffect(() => setSelected(0), [query]);
237
-
238
- const pick = useCallback(
239
- (item) => {
240
- onSelect?.(item);
241
- onClose();
242
- },
243
- [onSelect, onClose],
244
- );
245
-
246
- useEffect(() => {
247
- const onKey = (e) => {
248
- if (e.key === 'Escape') {
249
- e.preventDefault();
250
- onClose();
251
- } else if (e.key === 'ArrowDown') {
252
- e.preventDefault();
253
- navMode.current = 'keyboard';
254
- setSelected((s) => Math.min(s + 1, flat.length - 1));
255
- } else if (e.key === 'ArrowUp') {
256
- e.preventDefault();
257
- navMode.current = 'keyboard';
258
- setSelected((s) => Math.max(s - 1, 0));
259
- } else if (e.key === 'Enter') {
260
- e.preventDefault();
261
- if (flat[selected]) pick(flat[selected]);
262
- else onClose();
263
- }
264
- };
265
- window.addEventListener('keydown', onKey);
266
- return () => window.removeEventListener('keydown', onKey);
267
- }, [flat, selected, pick, onClose]);
268
-
269
- let rowIndex = -1;
270
-
271
- return (
272
- <div
273
- className="velu-search__scrim"
274
- onClick={onClose}
275
- role="presentation"
276
- >
277
- <div
278
- className="velu-search__palette"
279
- role="dialog"
280
- aria-modal="true"
281
- aria-label="Search"
282
- onClick={(e) => e.stopPropagation()}
283
- >
284
- <div className="velu-search__input-wrap">
285
- <span className="velu-search__input-icon" aria-hidden="true">
286
- {resolveIcon('search', { size: '1em' })}
287
- </span>
288
- <input
289
- ref={inputRef}
290
- className="velu-search__input"
291
- placeholder={placeholder}
292
- value={query}
293
- onChange={(e) => setQuery(e.target.value)}
294
- spellCheck={false}
295
- aria-label="Search query"
296
- />
297
- <span className="velu-search__kbd">esc</span>
298
- </div>
299
-
300
- <div className="velu-search__list" role="listbox">
301
- {grouped.length === 0 &&
302
- (search && !query.trim() ? (
303
- <div className="velu-search__empty">
304
- <span className="velu-search__empty-icon" aria-hidden="true">
305
- {resolveIcon('search', { size: '1em' })}
306
- </span>
307
- <div className="velu-search__empty-title">Search the docs</div>
308
- <div className="velu-search__empty-sub">
309
- Type to find pages and sections.
310
- </div>
311
- </div>
312
- ) : searching ? (
313
- <div className="velu-search__empty">
314
- <div className="velu-search__empty-sub">Searching…</div>
315
- </div>
316
- ) : (
317
- <div className="velu-search__empty">
318
- <span className="velu-search__empty-icon" aria-hidden="true">
319
- {resolveIcon('search-x', { size: '1em' })}
320
- </span>
321
- <div className="velu-search__empty-title">
322
- No results for &ldquo;{query}&rdquo;
323
- </div>
324
- <div className="velu-search__empty-sub">
325
- Try a different keyword or browse the sidebar.
326
- </div>
327
- </div>
328
- ))}
329
- {grouped.map(([group, items]) => (
330
- <div key={group}>
331
- <div className="velu-search__group">{group}</div>
332
- {items.map((item) => {
333
- rowIndex += 1;
334
- const idx = rowIndex;
335
- return (
336
- <PaletteRow
337
- key={item.id}
338
- item={item}
339
- selected={idx === selected}
340
- navMode={navMode}
341
- onHover={() => {
342
- navMode.current = 'mouse';
343
- setSelected(idx);
344
- }}
345
- onSelect={() => pick(item)}
346
- />
347
- );
348
- })}
349
- </div>
350
- ))}
351
- </div>
352
- </div>
353
- </div>
354
- );
355
- }
356
-
357
- /* Shown instead of the palette when search is unavailable (e.g. the local
358
- dev preview has no search index — that's generated when the site deploys).
359
- Reuses the palette's empty-state styling so it looks at home. */
360
- function SearchUnavailable({ message, onClose }) {
361
- useEffect(() => {
362
- const onKey = (e) => {
363
- if (e.key === 'Escape') {
364
- e.preventDefault();
365
- onClose();
366
- }
367
- };
368
- window.addEventListener('keydown', onKey);
369
- return () => window.removeEventListener('keydown', onKey);
370
- }, [onClose]);
371
- return (
372
- <div className="velu-search__scrim" onClick={onClose} role="presentation">
373
- <div
374
- className="velu-search__palette"
375
- role="dialog"
376
- aria-modal="true"
377
- aria-label="Search unavailable"
378
- onClick={(e) => e.stopPropagation()}
379
- >
380
- <div className="velu-search__empty">
381
- <span className="velu-search__empty-icon" aria-hidden="true">
382
- {resolveIcon('search-x', { size: '1em' })}
383
- </span>
384
- <div className="velu-search__empty-title">
385
- Search isn’t available in the dev preview
386
- </div>
387
- <div className="velu-search__empty-sub">{message}</div>
388
- </div>
389
- </div>
390
- </div>
391
- );
392
- }
393
-
394
- export default function Search({
395
- results = DEFAULT_RESULTS,
396
- search,
397
- placeholder = 'Search documentation',
398
- onSelect,
399
- unavailable = false,
400
- unavailableMessage = 'Full-text search is generated when your site is deployed — it isn’t available in the local preview.',
401
- className = '',
402
- ...rest
403
- }) {
404
- const [open, setOpen] = useState(false);
405
- // Show ⌘ K on Mac, Ctrl K elsewhere. SSR-safe: default to the
406
- // non-Mac label (so the server render matches the first client
407
- // render), then switch on the client if it's actually Mac.
408
- const [isMac, setIsMac] = useState(false);
409
- useEffect(() => {
410
- if (typeof navigator === 'undefined') return;
411
- const ua = navigator.userAgent || '';
412
- const plat = navigator.platform || '';
413
- setIsMac(/Mac|iPhone|iPad|iPod/i.test(plat) || /Mac/i.test(ua));
414
- }, []);
415
-
416
- // Global ⌘K (Mac) / Ctrl+K (Win/Linux) opens the palette.
417
- useEffect(() => {
418
- const onKey = (e) => {
419
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
420
- e.preventDefault();
421
- setOpen(true);
422
- }
423
- };
424
- window.addEventListener('keydown', onKey);
425
- return () => window.removeEventListener('keydown', onKey);
426
- }, []);
427
-
428
- return (
429
- <>
430
- <button
431
- type="button"
432
- className={`velu-search__trigger ${className}`.trim()}
433
- onClick={() => setOpen(true)}
434
- {...rest}
435
- >
436
- <span className="velu-search__trigger-icon" aria-hidden="true">
437
- {resolveIcon('search', { size: '1.5em' })}
438
- </span>
439
- <span className="velu-search__trigger-label">Search…</span>
440
- <span className="velu-search__kbd">{isMac ? '⌘ K' : 'Ctrl K'}</span>
441
- </button>
442
-
443
- {open &&
444
- (unavailable ? (
445
- <SearchUnavailable
446
- message={unavailableMessage}
447
- onClose={() => setOpen(false)}
448
- />
449
- ) : (
450
- <SearchPalette
451
- results={results}
452
- search={search}
453
- placeholder={placeholder}
454
- onSelect={onSelect}
455
- onClose={() => setOpen(false)}
456
- />
457
- ))}
458
- </>
459
- );
460
- }
1
+ import React, {
2
+ useState,
3
+ useRef,
4
+ useEffect,
5
+ useMemo,
6
+ useCallback,
7
+ } from 'react';
8
+ import resolveIcon from '../lib/resolveIcon.jsx';
9
+
10
+ /**
11
+ * Search — a Raycast-style command palette for the docs site. Ported
12
+ * from the Claude-Design "Velu Search" handoff and retokenized onto
13
+ * velu-ui's scale (themes for free via [data-theme]).
14
+ *
15
+ * <Search results={[…]} onSelect={(item) => …} />
16
+ *
17
+ * Renders a trigger box (drop it at the top of the page). Clicking it —
18
+ * or pressing ⌘K / Ctrl+K anywhere — reveals the palette: a scrim plus
19
+ * a centered panel with a search input and grouped, keyboard-navigable
20
+ * results (↑/↓ to move, Enter to pick, Esc / scrim-click to close).
21
+ *
22
+ * @typedef {Object} SearchResult
23
+ * @property {string} id
24
+ * @property {string} group 'Recent Searches' | 'Suggested' | 'Pages'
25
+ * @property {string[]} breadcrumb
26
+ * @property {string} title
27
+ * @property {'page'|'anchor'|'ext'} kind
28
+ * @property {string} desc
29
+ * @property {string} [href]
30
+ *
31
+ * @param {{ results?: SearchResult[], placeholder?: string,
32
+ * onSelect?: (item: SearchResult) => void,
33
+ * className?: string }} props
34
+ */
35
+
36
+ /* Sample result set — the demo data from the design. Override via the
37
+ `results` prop with real indexed content. */
38
+ const DEFAULT_RESULTS = [
39
+ {
40
+ id: 'auth-pw',
41
+ group: 'Recent Searches',
42
+ breadcrumb: ['Authentication setup', 'Password'],
43
+ title: 'Password',
44
+ kind: 'anchor',
45
+ desc: 'Password authentication provides access control through credentials.',
46
+ },
47
+ {
48
+ id: 'gh-sync',
49
+ group: 'Recent Searches',
50
+ breadcrumb: ['Get Started', 'GitHub & GitLab Sync'],
51
+ title: 'Enabling GitHub sync',
52
+ kind: 'page',
53
+ desc: 'Connect a GitHub repository to keep your docs in sync on every push.',
54
+ },
55
+ {
56
+ id: 'ai-search',
57
+ group: 'Recent Searches',
58
+ breadcrumb: ['Your Docs Site'],
59
+ title: 'AI Search',
60
+ kind: 'page',
61
+ desc: 'Configure semantic search for your documentation site.',
62
+ },
63
+ {
64
+ id: 'quickstart',
65
+ group: 'Suggested',
66
+ breadcrumb: ['Get Started'],
67
+ title: 'Quickstart',
68
+ kind: 'page',
69
+ desc: 'Set up Velu and publish your first docs site in under 5 minutes.',
70
+ },
71
+ {
72
+ id: 'overview',
73
+ group: 'Suggested',
74
+ breadcrumb: ['Get Started'],
75
+ title: 'Overview',
76
+ kind: 'page',
77
+ desc: 'Velu is an AI-native documentation platform built for humans and AI agents.',
78
+ },
79
+ {
80
+ id: 'site-settings',
81
+ group: 'Suggested',
82
+ breadcrumb: ['Your Docs Site'],
83
+ title: 'Site Settings',
84
+ kind: 'page',
85
+ desc: 'Configure domain, theme, navigation and SEO for your docs site.',
86
+ },
87
+ {
88
+ id: 'oauth',
89
+ group: 'Pages',
90
+ breadcrumb: ['Authentication setup', 'OAuth'],
91
+ title: 'OAuth',
92
+ kind: 'anchor',
93
+ desc: 'Configure OAuth providers for sign-in.',
94
+ },
95
+ {
96
+ id: 'saml',
97
+ group: 'Pages',
98
+ breadcrumb: ['Authentication setup', 'SAML'],
99
+ title: 'SAML',
100
+ kind: 'anchor',
101
+ desc: 'Enterprise single sign-on via SAML 2.0.',
102
+ },
103
+ ];
104
+
105
+ const GROUP_ORDER = ['Recent Searches', 'Suggested', 'Pages'];
106
+
107
+ /* Left-hand glyph: '#' for an anchor, an icon for page / external. */
108
+ function ResultGlyph({ kind }) {
109
+ if (kind === 'anchor') {
110
+ return <span className="velu-search__glyph-hash">#</span>;
111
+ }
112
+ const icon = kind === 'ext' ? 'external-link' : 'file-text';
113
+ return (
114
+ <span className="velu-search__glyph-icon">
115
+ {resolveIcon(icon, { size: '1em' })}
116
+ </span>
117
+ );
118
+ }
119
+
120
+ function Breadcrumb({ items }) {
121
+ return (
122
+ <div className="velu-search__crumbs">
123
+ {items.map((c, i) => (
124
+ <React.Fragment key={i}>
125
+ {i > 0 && <span className="velu-search__crumb-sep">›</span>}
126
+ <span>{c}</span>
127
+ </React.Fragment>
128
+ ))}
129
+ </div>
130
+ );
131
+ }
132
+
133
+ function PaletteRow({ item, selected, navMode, onHover, onSelect }) {
134
+ const ref = useRef(null);
135
+ useEffect(() => {
136
+ // Only auto-scroll for keyboard nav — on mouse hover the pointer is
137
+ // already on the row, and scrolling there nudges the list (a 1px
138
+ // jitter that flickers a line at the list edge).
139
+ if (selected && navMode.current === 'keyboard' && ref.current) {
140
+ ref.current.scrollIntoView({ block: 'nearest' });
141
+ }
142
+ }, [selected, navMode]);
143
+ return (
144
+ <div
145
+ ref={ref}
146
+ className={`velu-search__row${selected ? ' velu-search__row--selected' : ''}`}
147
+ onMouseEnter={onHover}
148
+ onClick={onSelect}
149
+ role="option"
150
+ aria-selected={selected}
151
+ >
152
+ <div className="velu-search__row-glyph">
153
+ <ResultGlyph kind={item.kind} />
154
+ </div>
155
+ <Breadcrumb items={item.breadcrumb} />
156
+ <div className="velu-search__row-title">{item.title}</div>
157
+ <div className="velu-search__row-desc">{item.desc}</div>
158
+ <div className="velu-search__row-meta">
159
+ <span className="velu-search__kbd">↵</span>
160
+ </div>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ /* The revealed palette — scrim + centered panel. When `search` (an async
166
+ query → results function, e.g. the Pagefind client) is provided, results
167
+ come from it; otherwise the static `results` list is filtered in-memory. */
168
+ function SearchPalette({ results, search, placeholder, onSelect, onClose }) {
169
+ const [query, setQuery] = useState('');
170
+ const [selected, setSelected] = useState(0);
171
+ const [asyncResults, setAsyncResults] = useState([]);
172
+ const [searching, setSearching] = useState(false);
173
+ const inputRef = useRef(null);
174
+ // 'keyboard' | 'mouse' — which input last moved the selection; gates
175
+ // the rows' scrollIntoView so mouse hover doesn't jitter the list.
176
+ const navMode = useRef('keyboard');
177
+
178
+ useEffect(() => {
179
+ inputRef.current?.focus();
180
+ }, []);
181
+
182
+ // Async source (Pagefind): debounce the query, fetch results.
183
+ useEffect(() => {
184
+ if (!search) return undefined;
185
+ const q = query.trim();
186
+ if (!q) {
187
+ setAsyncResults([]);
188
+ setSearching(false);
189
+ return undefined;
190
+ }
191
+ let cancelled = false;
192
+ setSearching(true);
193
+ const t = setTimeout(async () => {
194
+ const r = await search(q).catch(() => []);
195
+ if (!cancelled) {
196
+ setAsyncResults(Array.isArray(r) ? r : []);
197
+ setSearching(false);
198
+ }
199
+ }, 150);
200
+ return () => {
201
+ cancelled = true;
202
+ clearTimeout(t);
203
+ };
204
+ }, [query, search]);
205
+
206
+ const filtered = useMemo(() => {
207
+ if (search) return asyncResults;
208
+ const q = query.trim().toLowerCase();
209
+ if (!q) {
210
+ // Empty query → just the recents.
211
+ return results.filter((r) => r.group === 'Recent Searches');
212
+ }
213
+ return results.filter((r) => {
214
+ const hay = `${r.title} ${r.breadcrumb.join(' ')} ${r.desc}`.toLowerCase();
215
+ return hay.includes(q);
216
+ });
217
+ }, [search, asyncResults, query, results]);
218
+
219
+ const grouped = useMemo(() => {
220
+ const map = new Map();
221
+ filtered.forEach((r) => {
222
+ const g = r.group || 'Pages';
223
+ if (!map.has(g)) map.set(g, []);
224
+ map.get(g).push(r);
225
+ });
226
+ const known = GROUP_ORDER.filter((g) => map.has(g));
227
+ const extra = [...map.keys()].filter((g) => !GROUP_ORDER.includes(g));
228
+ return [...known, ...extra].map((g) => [g, map.get(g)]);
229
+ }, [filtered]);
230
+
231
+ const flat = useMemo(
232
+ () => grouped.flatMap(([, items]) => items),
233
+ [grouped],
234
+ );
235
+
236
+ useEffect(() => setSelected(0), [query]);
237
+
238
+ const pick = useCallback(
239
+ (item) => {
240
+ onSelect?.(item);
241
+ onClose();
242
+ },
243
+ [onSelect, onClose],
244
+ );
245
+
246
+ useEffect(() => {
247
+ const onKey = (e) => {
248
+ if (e.key === 'Escape') {
249
+ e.preventDefault();
250
+ onClose();
251
+ } else if (e.key === 'ArrowDown') {
252
+ e.preventDefault();
253
+ navMode.current = 'keyboard';
254
+ setSelected((s) => Math.min(s + 1, flat.length - 1));
255
+ } else if (e.key === 'ArrowUp') {
256
+ e.preventDefault();
257
+ navMode.current = 'keyboard';
258
+ setSelected((s) => Math.max(s - 1, 0));
259
+ } else if (e.key === 'Enter') {
260
+ e.preventDefault();
261
+ if (flat[selected]) pick(flat[selected]);
262
+ else onClose();
263
+ }
264
+ };
265
+ window.addEventListener('keydown', onKey);
266
+ return () => window.removeEventListener('keydown', onKey);
267
+ }, [flat, selected, pick, onClose]);
268
+
269
+ let rowIndex = -1;
270
+
271
+ return (
272
+ <div
273
+ className="velu-search__scrim"
274
+ onClick={onClose}
275
+ role="presentation"
276
+ >
277
+ <div
278
+ className="velu-search__palette"
279
+ role="dialog"
280
+ aria-modal="true"
281
+ aria-label="Search"
282
+ onClick={(e) => e.stopPropagation()}
283
+ >
284
+ <div className="velu-search__input-wrap">
285
+ <span className="velu-search__input-icon" aria-hidden="true">
286
+ {resolveIcon('search', { size: '1em' })}
287
+ </span>
288
+ <input
289
+ ref={inputRef}
290
+ className="velu-search__input"
291
+ placeholder={placeholder}
292
+ value={query}
293
+ onChange={(e) => setQuery(e.target.value)}
294
+ spellCheck={false}
295
+ aria-label="Search query"
296
+ />
297
+ <span className="velu-search__kbd">esc</span>
298
+ </div>
299
+
300
+ <div className="velu-search__list" role="listbox">
301
+ {grouped.length === 0 &&
302
+ (search && !query.trim() ? (
303
+ <div className="velu-search__empty">
304
+ <span className="velu-search__empty-icon" aria-hidden="true">
305
+ {resolveIcon('search', { size: '1em' })}
306
+ </span>
307
+ <div className="velu-search__empty-title">Search the docs</div>
308
+ <div className="velu-search__empty-sub">
309
+ Type to find pages and sections.
310
+ </div>
311
+ </div>
312
+ ) : searching ? (
313
+ <div className="velu-search__empty">
314
+ <div className="velu-search__empty-sub">Searching…</div>
315
+ </div>
316
+ ) : (
317
+ <div className="velu-search__empty">
318
+ <span className="velu-search__empty-icon" aria-hidden="true">
319
+ {resolveIcon('search-x', { size: '1em' })}
320
+ </span>
321
+ <div className="velu-search__empty-title">
322
+ No results for &ldquo;{query}&rdquo;
323
+ </div>
324
+ <div className="velu-search__empty-sub">
325
+ Try a different keyword or browse the sidebar.
326
+ </div>
327
+ </div>
328
+ ))}
329
+ {grouped.map(([group, items]) => (
330
+ <div key={group}>
331
+ <div className="velu-search__group">{group}</div>
332
+ {items.map((item) => {
333
+ rowIndex += 1;
334
+ const idx = rowIndex;
335
+ return (
336
+ <PaletteRow
337
+ key={item.id}
338
+ item={item}
339
+ selected={idx === selected}
340
+ navMode={navMode}
341
+ onHover={() => {
342
+ navMode.current = 'mouse';
343
+ setSelected(idx);
344
+ }}
345
+ onSelect={() => pick(item)}
346
+ />
347
+ );
348
+ })}
349
+ </div>
350
+ ))}
351
+ </div>
352
+ </div>
353
+ </div>
354
+ );
355
+ }
356
+
357
+ /* Shown instead of the palette when search is unavailable (e.g. the local
358
+ dev preview has no search index — that's generated when the site deploys).
359
+ Reuses the palette's empty-state styling so it looks at home. */
360
+ function SearchUnavailable({ message, onClose }) {
361
+ useEffect(() => {
362
+ const onKey = (e) => {
363
+ if (e.key === 'Escape') {
364
+ e.preventDefault();
365
+ onClose();
366
+ }
367
+ };
368
+ window.addEventListener('keydown', onKey);
369
+ return () => window.removeEventListener('keydown', onKey);
370
+ }, [onClose]);
371
+ return (
372
+ <div className="velu-search__scrim" onClick={onClose} role="presentation">
373
+ <div
374
+ className="velu-search__palette"
375
+ role="dialog"
376
+ aria-modal="true"
377
+ aria-label="Search unavailable"
378
+ onClick={(e) => e.stopPropagation()}
379
+ >
380
+ <div className="velu-search__empty">
381
+ <span className="velu-search__empty-icon" aria-hidden="true">
382
+ {resolveIcon('search-x', { size: '1em' })}
383
+ </span>
384
+ <div className="velu-search__empty-title">
385
+ Search isn’t available in the dev preview
386
+ </div>
387
+ <div className="velu-search__empty-sub">{message}</div>
388
+ </div>
389
+ </div>
390
+ </div>
391
+ );
392
+ }
393
+
394
+ export default function Search({
395
+ results = DEFAULT_RESULTS,
396
+ search,
397
+ placeholder = 'Search documentation',
398
+ onSelect,
399
+ unavailable = false,
400
+ unavailableMessage = 'Full-text search is generated when your site is deployed — it isn’t available in the local preview.',
401
+ className = '',
402
+ ...rest
403
+ }) {
404
+ const [open, setOpen] = useState(false);
405
+ // Show ⌘ K on Mac, Ctrl K elsewhere. SSR-safe: default to the
406
+ // non-Mac label (so the server render matches the first client
407
+ // render), then switch on the client if it's actually Mac.
408
+ const [isMac, setIsMac] = useState(false);
409
+ useEffect(() => {
410
+ if (typeof navigator === 'undefined') return;
411
+ const ua = navigator.userAgent || '';
412
+ const plat = navigator.platform || '';
413
+ setIsMac(/Mac|iPhone|iPad|iPod/i.test(plat) || /Mac/i.test(ua));
414
+ }, []);
415
+
416
+ // Global ⌘K (Mac) / Ctrl+K (Win/Linux) opens the palette.
417
+ useEffect(() => {
418
+ const onKey = (e) => {
419
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
420
+ e.preventDefault();
421
+ setOpen(true);
422
+ }
423
+ };
424
+ window.addEventListener('keydown', onKey);
425
+ return () => window.removeEventListener('keydown', onKey);
426
+ }, []);
427
+
428
+ return (
429
+ <>
430
+ <button
431
+ type="button"
432
+ className={`velu-search__trigger ${className}`.trim()}
433
+ onClick={() => setOpen(true)}
434
+ {...rest}
435
+ >
436
+ <span className="velu-search__trigger-icon" aria-hidden="true">
437
+ {resolveIcon('search', { size: '1.5em' })}
438
+ </span>
439
+ <span className="velu-search__trigger-label">Search…</span>
440
+ <span className="velu-search__kbd">{isMac ? '⌘ K' : 'Ctrl K'}</span>
441
+ </button>
442
+
443
+ {open &&
444
+ (unavailable ? (
445
+ <SearchUnavailable
446
+ message={unavailableMessage}
447
+ onClose={() => setOpen(false)}
448
+ />
449
+ ) : (
450
+ <SearchPalette
451
+ results={results}
452
+ search={search}
453
+ placeholder={placeholder}
454
+ onSelect={onSelect}
455
+ onClose={() => setOpen(false)}
456
+ />
457
+ ))}
458
+ </>
459
+ );
460
+ }