@veluai/velu 0.2.25 → 0.2.27

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 (114) hide show
  1. package/README.md +80 -80
  2. package/dist/cli.js +44 -44
  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 +125 -125
  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/ChangelogFilters.jsx +75 -75
  16. package/runtime/velu-ui/components/Chatbot.jsx +972 -972
  17. package/runtime/velu-ui/components/CodeBlock.jsx +375 -375
  18. package/runtime/velu-ui/components/Columns.jsx +56 -56
  19. package/runtime/velu-ui/components/ContextMenu.jsx +327 -327
  20. package/runtime/velu-ui/components/ErrorCard.jsx +138 -138
  21. package/runtime/velu-ui/components/Field.jsx +81 -81
  22. package/runtime/velu-ui/components/Image.jsx +163 -163
  23. package/runtime/velu-ui/components/Logo.jsx +31 -31
  24. package/runtime/velu-ui/components/MethodBadge.jsx +31 -31
  25. package/runtime/velu-ui/components/NavSelect.jsx +108 -108
  26. package/runtime/velu-ui/components/NotFound.jsx +63 -63
  27. package/runtime/velu-ui/components/PageFeedback.jsx +219 -219
  28. package/runtime/velu-ui/components/PageFooter.jsx +145 -145
  29. package/runtime/velu-ui/components/PageHeader.jsx +422 -422
  30. package/runtime/velu-ui/components/PageNav.jsx +77 -77
  31. package/runtime/velu-ui/components/PoweredBy.jsx +51 -51
  32. package/runtime/velu-ui/components/Prompt.jsx +115 -115
  33. package/runtime/velu-ui/components/Search.jsx +460 -460
  34. package/runtime/velu-ui/components/Sidebar.jsx +246 -246
  35. package/runtime/velu-ui/components/SocialLinks.jsx +90 -90
  36. package/runtime/velu-ui/components/Steps.jsx +65 -65
  37. package/runtime/velu-ui/components/ThemeToggle.jsx +48 -48
  38. package/runtime/velu-ui/components/Toc.jsx +537 -537
  39. package/runtime/velu-ui/components/TocBar.jsx +195 -195
  40. package/runtime/velu-ui/components/Tree.jsx +87 -87
  41. package/runtime/velu-ui/components/TryItBar.jsx +102 -102
  42. package/runtime/velu-ui/components/Update.jsx +92 -92
  43. package/runtime/velu-ui/components/accordion.css +92 -92
  44. package/runtime/velu-ui/components/api-page.css +208 -208
  45. package/runtime/velu-ui/components/api.css +635 -635
  46. package/runtime/velu-ui/components/ask-bar.css +103 -103
  47. package/runtime/velu-ui/components/card.css +105 -105
  48. package/runtime/velu-ui/components/changelog-filters.css +43 -43
  49. package/runtime/velu-ui/components/chatbot.css +687 -687
  50. package/runtime/velu-ui/components/code-block.css +263 -263
  51. package/runtime/velu-ui/components/context-menu.css +199 -199
  52. package/runtime/velu-ui/components/docs-layout.css +822 -822
  53. package/runtime/velu-ui/components/field.css +82 -82
  54. package/runtime/velu-ui/components/image.css +237 -237
  55. package/runtime/velu-ui/components/nav-select.css +157 -157
  56. package/runtime/velu-ui/components/not-found.css +94 -94
  57. package/runtime/velu-ui/components/page-feedback.css +261 -261
  58. package/runtime/velu-ui/components/page-footer.css +130 -130
  59. package/runtime/velu-ui/components/page-header.css +558 -558
  60. package/runtime/velu-ui/components/page-nav.css +50 -50
  61. package/runtime/velu-ui/components/powered-by.css +92 -92
  62. package/runtime/velu-ui/components/prompt.css +99 -99
  63. package/runtime/velu-ui/components/search.css +307 -307
  64. package/runtime/velu-ui/components/sidebar.css +205 -205
  65. package/runtime/velu-ui/components/steps.css +77 -77
  66. package/runtime/velu-ui/components/theme-toggle.css +102 -102
  67. package/runtime/velu-ui/components/toc-bar.css +234 -234
  68. package/runtime/velu-ui/components/tree.css +49 -49
  69. package/runtime/velu-ui/components/update.css +111 -111
  70. package/runtime/velu-ui/index.js +58 -58
  71. package/runtime/velu-ui/lib/api-send.js +118 -118
  72. package/runtime/velu-ui/lib/brand-icons.jsx +103 -103
  73. package/runtime/velu-ui/lib/component-schemas.js +100 -100
  74. package/runtime/velu-ui/lib/copyText.js +64 -64
  75. package/runtime/velu-ui/lib/docs-assistant.js +273 -273
  76. package/runtime/velu-ui/lib/lang-icons.jsx +147 -147
  77. package/runtime/velu-ui/lib/page-feedback.js +72 -72
  78. package/runtime/velu-ui/lib/pagefind.js +116 -116
  79. package/runtime/velu-ui/lib/prism-langs.js +957 -957
  80. package/runtime/velu-ui/lib/prism-loader.js +74 -74
  81. package/runtime/velu-ui/lib/resolveIcon.jsx +29 -29
  82. package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -66
  83. package/runtime/velu-ui/mdx-components.jsx +107 -107
  84. package/runtime/velu-ui/primitives/Cluster.jsx +49 -49
  85. package/runtime/velu-ui/primitives/Stack.jsx +63 -63
  86. package/runtime/velu-ui/primitives/Switcher.jsx +57 -57
  87. package/runtime/velu-ui/primitives/stack.css +3 -3
  88. package/runtime/velu-ui/primitives/switcher.css +25 -25
  89. package/runtime/velu-ui/styles.css +48 -48
  90. package/runtime/velu-ui/tokens.css +4 -4
  91. package/schema/velu.schema.json +419 -419
  92. package/src/lib/extract-mdx-error.js +170 -170
  93. package/src/lib/issues.js +159 -159
  94. package/src/lib/known-components.js +35 -35
  95. package/src/navigation.js +459 -459
  96. package/src/runtime/App.jsx +1754 -1754
  97. package/src/runtime/ErrorBoundary.jsx +54 -54
  98. package/src/runtime/client-entry.jsx +27 -27
  99. package/src/runtime/server-entry.jsx +24 -24
  100. package/src/template.html +48 -48
  101. package/templates/starter/ai-tools/claude-code.mdx +26 -26
  102. package/templates/starter/ai-tools/cursor.mdx +17 -17
  103. package/templates/starter/api-reference/introduction.mdx +43 -43
  104. package/templates/starter/development.mdx +19 -19
  105. package/templates/starter/essentials/code.mdx +29 -29
  106. package/templates/starter/essentials/images.mdx +29 -29
  107. package/templates/starter/essentials/markdown.mdx +25 -25
  108. package/templates/starter/essentials/navigation.mdx +39 -39
  109. package/templates/starter/essentials/settings.mdx +30 -30
  110. package/templates/starter/favicon.svg +6 -6
  111. package/templates/starter/index.mdx +31 -31
  112. package/templates/starter/openapi.json +160 -160
  113. package/templates/starter/quickstart.mdx +31 -31
  114. package/templates/starter/velu.json +40 -40
@@ -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
+ }