@veluai/velu 0.2.0 → 0.2.2

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.
@@ -7,10 +7,51 @@
7
7
  rotation, and centralized icon sizing/stroke. All values are tokens. */
8
8
 
9
9
  .velu-sidebar {
10
- border-left: var(--border-width) solid var(--surface-color);
10
+ position: relative; /* anchors the gliding active indicator */
11
11
  margin-left: var(--s1);
12
12
  }
13
13
 
14
+ /* The single active indicator — a crimson tint band with a flush-left 2px bar
15
+ that GLIDES between items (one element animating its position/height) instead
16
+ of each item painting its own active state. z-index 0 keeps it BEHIND the
17
+ items + sticky headings (both positioned, z-index 1), so the text reads over
18
+ the tint and a pinned heading's opaque background covers the indicator when an
19
+ item scrolls under it. Driven imperatively from JS (transform/height/opacity);
20
+ the easing curve makes the slide feel continuous. */
21
+ .velu-sidebar__indicator {
22
+ position: absolute;
23
+ inset-inline-start: 0;
24
+ inset-inline-end: var(--s-3);
25
+ inset-block-start: 0;
26
+ height: 0;
27
+ opacity: 0;
28
+ z-index: 0;
29
+ pointer-events: none;
30
+ background: color-mix(in srgb, var(--accent-color) 8%, transparent);
31
+ border-radius: var(--radius-sm);
32
+ transition:
33
+ transform 0.3s cubic-bezier(0.32, 0.72, 0, 1),
34
+ height 0.3s cubic-bezier(0.32, 0.72, 0, 1),
35
+ opacity 0.2s ease;
36
+ will-change: transform, height;
37
+ }
38
+ .velu-sidebar__indicator::before {
39
+ content: '';
40
+ position: absolute;
41
+ inset-inline-start: 0;
42
+ inset-block: 15%;
43
+ inline-size: 2px;
44
+ border-start-end-radius: 2px;
45
+ border-end-end-radius: 2px;
46
+ background: var(--accent-color);
47
+ }
48
+ /* Honour reduced-motion: snap instead of glide. */
49
+ @media (prefers-reduced-motion: reduce) {
50
+ .velu-sidebar__indicator {
51
+ transition: opacity 0.2s ease;
52
+ }
53
+ }
54
+
14
55
  .velu-sidebar__section {
15
56
  /* Sticky section header: each heading pins to the top of the scroll
16
57
  region until its own section scrolls past, then the next heading
@@ -21,7 +62,10 @@
21
62
  bar is invisible until it's actually covering scrolled content. */
22
63
  position: sticky;
23
64
  inset-block-start: 0;
24
- z-index: 1;
65
+ /* Above the items (z-index 1) so scrolled rows tuck UNDER the heading (its
66
+ opaque bg + the ::after fade hide them), not over it; above the gliding
67
+ indicator (z-index 0) too. */
68
+ z-index: 2;
25
69
  background: var(--page-bg);
26
70
  display: flex;
27
71
  align-items: center;
@@ -94,21 +138,38 @@
94
138
  so these stay CSS until/if a Cluster/Inline primitive exists. */
95
139
  .velu-sidebar__item,
96
140
  .velu-sidebar__subitem {
141
+ /* Positioned above the gliding indicator (z-index 0) so the label reads over
142
+ the tint band. */
143
+ position: relative;
144
+ z-index: 1;
97
145
  display: flex;
98
146
  align-items: center;
99
147
  gap: var(--s-3);
100
148
  padding-inline: var(--s0);
101
149
  color: var(--text-color);
102
150
  text-decoration: none;
151
+ transition: color 0.2s ease;
103
152
  }
104
153
 
105
154
  .velu-sidebar__subitem {
106
155
  padding-block: var(--s-5);
107
156
  }
108
157
 
158
+ /* Active = colour + weight only; the moving indicator paints the bar + tint. */
109
159
  .velu-sidebar__item--active {
110
160
  color: var(--accent-color);
111
- border-left: 2px solid var(--accent-color);
161
+ font-weight: 500;
162
+ }
163
+ .velu-sidebar__item--active .velu-sidebar__icon {
164
+ color: var(--accent-color);
165
+ }
166
+
167
+ /* The label fills the row so the group chevron (and any external-link icon)
168
+ sit flush at the row's RIGHT end, not crowded against the label — faithful
169
+ to the design's `flex: 1` label. */
170
+ .velu-sidebar__label {
171
+ flex: 1;
172
+ min-inline-size: 0;
112
173
  }
113
174
 
114
175
  .velu-sidebar__summary {
@@ -39,6 +39,7 @@
39
39
  transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
40
40
  }
41
41
  .velu-theme-toggle:hover {
42
+ background: var(--surface-color);
42
43
  border-color: var(--accent-color);
43
44
  }
44
45
  /* Tactile press feedback on click. */
@@ -42,6 +42,8 @@ export {
42
42
  VeluMark,
43
43
  } from './components/PageHeader.jsx';
44
44
  export { default as Search } from './components/Search.jsx';
45
+ export { default as pagefindSearch } from './lib/pagefind.js';
46
+ export { createDocsAssistant } from './lib/docs-assistant.js';
45
47
  export { default as Image } from './components/Image.jsx';
46
48
  export {
47
49
  default as CodeBlock,
@@ -6,9 +6,10 @@ import React from 'react';
6
6
  * like lucide — no SVG loader). Each takes a `size` prop, mirroring lucide's
7
7
  * icon API so they're drop-in in the menu.
8
8
  *
9
- * Monochrome marks (OpenAI, Cursor) use `currentColor` so they follow the menu
10
- * text/hover color across light + dark. Multi-color marks (Claude, Perplexity,
11
- * VS Code) keep their brand colors legible on both themes.
9
+ * ALL marks use `currentColor` so the icon column reads as one uniform set:
10
+ * muted/grey at rest, accent on hover (matching the lucide icons beside them).
11
+ * The brand glyphs keep their distinctive shapes; only the fill follows the
12
+ * menu's text/hover color across light + dark.
12
13
  */
13
14
 
14
15
  export function MarkdownIcon({ size = '1em', ...rest }) {
@@ -39,7 +40,7 @@ export function OpenAIIcon({ size = '1em', ...rest }) {
39
40
  export function ClaudeIcon({ size = '1em', ...rest }) {
40
41
  return (
41
42
  <svg width={size} height={size} viewBox="0 0 256 257" aria-hidden="true" {...rest}>
42
- <path fill="#D97757" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z" />
43
+ <path fill="currentColor" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z" />
43
44
  </svg>
44
45
  );
45
46
  }
@@ -47,7 +48,7 @@ export function ClaudeIcon({ size = '1em', ...rest }) {
47
48
  export function PerplexityIcon({ size = '1em', ...rest }) {
48
49
  return (
49
50
  <svg width={size} height={size} viewBox="0 0 48 48" fill="none" aria-hidden="true" {...rest}>
50
- <g stroke="#20808d" strokeLinecap="round" strokeLinejoin="round">
51
+ <g stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
51
52
  <path d="M24 4.5v39M13.73 16.573v-9.99L24 16.573m0 14.5L13.73 41.417V27.01L24 16.573m0 0l10.27-9.99v9.99" />
52
53
  <path d="M13.73 31.396H9.44V16.573h29.12v14.823h-4.29" />
53
54
  <path d="M24 16.573L34.27 27.01v14.407L24 31.073" />
@@ -85,15 +86,15 @@ export function VscodeIcon({ size = '1em', ...rest }) {
85
86
  </mask>
86
87
  <g mask="url(#velu-vsc-mask)">
87
88
  <path
88
- fill="#0065A9"
89
+ fill="currentColor"
89
90
  d="M96.461 10.796 75.857.876a6.23 6.23 0 0 0-7.107 1.207l-67.451 61.5a4.167 4.167 0 0 0 .004 6.162l5.51 5.009a4.167 4.167 0 0 0 5.32.236l81.228-61.62c2.725-2.067 6.639-.124 6.639 3.297v-.24a6.25 6.25 0 0 0-3.539-5.63Z"
90
91
  />
91
92
  <path
92
- fill="#007ACC"
93
+ fill="currentColor"
93
94
  d="m96.461 89.204-20.604 9.92a6.229 6.229 0 0 1-7.107-1.207l-67.451-61.5a4.167 4.167 0 0 1 .004-6.162l5.51-5.009a4.167 4.167 0 0 1 5.32-.236l81.228 61.62c2.725 2.067 6.639.124 6.639-3.297v.24a6.25 6.25 0 0 1-3.539 5.63Z"
94
95
  />
95
96
  <path
96
- fill="#1F9CF0"
97
+ fill="currentColor"
97
98
  d="M75.858 99.126a6.232 6.232 0 0 1-7.108-1.21c2.306 2.307 6.25.674 6.25-2.588V4.672c0-3.262-3.944-4.895-6.25-2.589a6.232 6.232 0 0 1 7.108-1.21l20.6 9.908A6.25 6.25 0 0 1 100 16.413v67.174a6.25 6.25 0 0 1-3.541 5.633l-20.601 9.906Z"
98
99
  />
99
100
  </g>
@@ -0,0 +1,163 @@
1
+ /**
2
+ * docs-assistant — runtime client for the published docs site's "Ask AI",
3
+ * talking to the backend's PUBLIC docs-AI-assistant API:
4
+ *
5
+ * GET /api/v1/public/ai-assistant/bootstrap (visitor cookie + tenant)
6
+ * POST /api/v1/public/ai-assistant/messages ({ message, conversation_id })
7
+ * GET /api/v1/public/ai-assistant/conversations/{id}/events (SSE; ?token=…)
8
+ * POST|DELETE /api/v1/public/ai-assistant/messages/{id}/feedback ({ rating })
9
+ *
10
+ * `createDocsAssistant({ apiBase, host })` returns `{ ask, sendFeedback }`,
11
+ * shaped to drop straight into <Chatbot ask onFeedback>. `ask` is an async
12
+ * generator yielding the AskEvent shape Chatbot expects.
13
+ *
14
+ * Contract notes (from the backend):
15
+ * - bootstrap sets an **httponly visitor cookie**, so every call uses
16
+ * `credentials: 'include'`. Cross-origin therefore needs CORS w/ credentials
17
+ * AND a cross-site-capable cookie (SameSite=None) — today the cookie is
18
+ * SameSite=Lax, so the API must be same-site with the docs (proxied under the
19
+ * docs domain) until the backend relaxes it. See the plan's open questions.
20
+ * - tenant is resolved from the `X-Velu-Site-Host` header (falls back to Origin).
21
+ * - POST /messages returns immediately; the answer is generated in the
22
+ * background and arrives as a SINGLE `assistant.completed` SSE event with the
23
+ * full content + citations (NOT token-by-token). So `ask` yields one content
24
+ * event when the answer is ready (the panel shows its "thinking" state until).
25
+ */
26
+
27
+ const trimSlash = (s) => String(s || '').replace(/\/+$/, '');
28
+
29
+ function siteHost(explicit) {
30
+ if (explicit) return explicit;
31
+ return typeof window !== 'undefined' ? window.location.host : '';
32
+ }
33
+
34
+ // Parse one raw SSE record ("event: x\ndata: {…}") into { event, data }.
35
+ function parseSseRecord(raw) {
36
+ let event = 'message';
37
+ const dataLines = [];
38
+ for (const line of raw.split('\n')) {
39
+ if (line.startsWith('event:')) event = line.slice(6).trim();
40
+ else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''));
41
+ }
42
+ if (!dataLines.length) return { event, data: null };
43
+ try {
44
+ return { event, data: JSON.parse(dataLines.join('\n')) };
45
+ } catch {
46
+ return { event, data: null };
47
+ }
48
+ }
49
+
50
+ // Backend citation → the Chatbot's Source shape.
51
+ function mapCitations(cits) {
52
+ if (!Array.isArray(cits)) return undefined;
53
+ return cits.map((c, i) => ({
54
+ num: c.num ?? c.index ?? i + 1,
55
+ title: c.title,
56
+ path: c.route_path ?? c.path,
57
+ url: c.url,
58
+ }));
59
+ }
60
+
61
+ export function createDocsAssistant({ apiBase, host } = {}) {
62
+ const base = trimSlash(apiBase);
63
+ if (!base) return null;
64
+ const root = `${base}/api/v1/public/ai-assistant`;
65
+ let bootstrapped = false;
66
+
67
+ const headers = (extra) => ({
68
+ 'X-Velu-Site-Host': siteHost(host),
69
+ ...extra,
70
+ });
71
+
72
+ async function bootstrap() {
73
+ if (bootstrapped) return;
74
+ // Sets the httponly visitor cookie used by every subsequent call.
75
+ await fetch(`${root}/bootstrap`, {
76
+ method: 'GET',
77
+ credentials: 'include',
78
+ headers: headers(),
79
+ });
80
+ bootstrapped = true;
81
+ }
82
+
83
+ // Async generator: yields { conversationId } early, then one
84
+ // { delta, citations, messageId, conversationId } when the answer is ready.
85
+ async function* ask(prompt, { conversationId, signal } = {}) {
86
+ await bootstrap();
87
+
88
+ const res = await fetch(`${root}/messages`, {
89
+ method: 'POST',
90
+ credentials: 'include',
91
+ headers: headers({ 'Content-Type': 'application/json' }),
92
+ body: JSON.stringify({ message: prompt, conversation_id: conversationId ?? null }),
93
+ signal,
94
+ });
95
+ if (!res.ok) throw new Error(`ask-ai: messages ${res.status}`);
96
+ const { conversation_id: convId, conversation_token: token } = await res.json();
97
+ yield { conversationId: convId };
98
+
99
+ // Manual fetch-stream SSE parse (not EventSource): supports credentials,
100
+ // the X-Velu-Site-Host header, and AbortController cancellation.
101
+ const evRes = await fetch(
102
+ `${root}/conversations/${convId}/events?after_seq=0&token=${encodeURIComponent(token)}`,
103
+ { credentials: 'include', headers: headers({ Accept: 'text/event-stream' }), signal },
104
+ );
105
+ if (!evRes.ok || !evRes.body) throw new Error(`ask-ai: stream ${evRes.status}`);
106
+
107
+ const reader = evRes.body.getReader();
108
+ const decoder = new TextDecoder();
109
+ let buf = '';
110
+ try {
111
+ while (true) {
112
+ const { value, done } = await reader.read();
113
+ if (done) break;
114
+ buf += decoder.decode(value, { stream: true });
115
+ let sep;
116
+ while ((sep = buf.indexOf('\n\n')) !== -1) {
117
+ const { event, data } = parseSseRecord(buf.slice(0, sep));
118
+ buf = buf.slice(sep + 2);
119
+ if (event === 'assistant.completed') {
120
+ const m = data?.message || {};
121
+ yield {
122
+ delta: m.content || '',
123
+ citations: mapCitations(m.citations),
124
+ messageId: m.id,
125
+ conversationId: convId,
126
+ };
127
+ return; // answer delivered — done
128
+ }
129
+ if (event === 'assistant.error') {
130
+ throw new Error(data?.error || 'ask-ai: generation failed');
131
+ }
132
+ // message.created (echoed user turn) and ping are ignored.
133
+ }
134
+ }
135
+ } finally {
136
+ try {
137
+ await reader.cancel();
138
+ } catch {
139
+ /* already closed */
140
+ }
141
+ }
142
+ }
143
+
144
+ // rating: 'up' | 'down' to set, null to retract.
145
+ async function sendFeedback(messageId, rating) {
146
+ if (messageId == null) return;
147
+ const url = `${root}/messages/${messageId}/feedback`;
148
+ if (rating == null) {
149
+ await fetch(url, { method: 'DELETE', credentials: 'include', headers: headers() });
150
+ return;
151
+ }
152
+ await fetch(url, {
153
+ method: 'POST',
154
+ credentials: 'include',
155
+ headers: headers({ 'Content-Type': 'application/json' }),
156
+ body: JSON.stringify({ rating }),
157
+ });
158
+ }
159
+
160
+ return { ask, sendFeedback };
161
+ }
162
+
163
+ export default createDocsAssistant;
@@ -0,0 +1,113 @@
1
+ // Pagefind client — queries the static search index that `velu build` writes to
2
+ // /pagefind/. The index is a chunked, lazy-loaded WASM bundle: the browser only
3
+ // downloads the fragments it needs per query, so search scales to huge sites
4
+ // without shipping one giant index. Generated at build time; absent in dev
5
+ // (the dev preview shows search as "unavailable").
6
+ //
7
+ // The runtime is loaded via a NON-analyzable dynamic import (a Function-wrapped
8
+ // import) so neither Vite nor the bundler tries to resolve /pagefind/pagefind.js
9
+ // at build time — it only exists in the deployed output.
10
+
11
+ let _pf = null;
12
+ let _loading = null;
13
+
14
+ async function loadRuntime() {
15
+ if (_pf) return _pf;
16
+ if (_loading) return _loading;
17
+ _loading = (async () => {
18
+ const importer = new Function('p', 'return import(p)');
19
+ const mod = await importer('/pagefind/pagefind.js');
20
+ if (typeof mod.init === 'function') await mod.init();
21
+ _pf = mod;
22
+ return mod;
23
+ })();
24
+ try {
25
+ return await _loading;
26
+ } catch (err) {
27
+ _loading = null;
28
+ throw err;
29
+ }
30
+ }
31
+
32
+ const stripHtml = (s) => String(s || '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
33
+
34
+ // Pagefind returns directory URLs (/quickstart/, /a/b/, /); normalize to the
35
+ // router's path form (/quickstart, /a/b, /). Preserve any #anchor.
36
+ function normalizeUrl(url) {
37
+ if (!url) return '/';
38
+ let [path, hash] = String(url).split('#');
39
+ path = path.replace(/index\.html?$/i, '').replace(/\.html?$/i, '');
40
+ if (path.length > 1) path = path.replace(/\/+$/, '');
41
+ if (!path) path = '/';
42
+ return hash ? `${path}#${hash}` : path;
43
+ }
44
+
45
+ // Breadcrumb from the URL path (everything but the leaf), title-cased.
46
+ function crumbsFromUrl(url) {
47
+ const segs = String(url).split('#')[0].split('/').filter(Boolean);
48
+ segs.pop();
49
+ return segs.map((s) => s.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()));
50
+ }
51
+
52
+ /**
53
+ * Run a query against the static Pagefind index.
54
+ * @returns {Promise<Array<SearchResult>|null>} palette-shaped results, or null
55
+ * when the index can't be loaded (→ caller can show "unavailable").
56
+ */
57
+ export default async function pagefindSearch(query) {
58
+ const q = String(query || '').trim();
59
+ if (!q) return [];
60
+
61
+ let pf;
62
+ try {
63
+ pf = await loadRuntime();
64
+ } catch {
65
+ return null; // index not present (e.g. not built) → unavailable
66
+ }
67
+
68
+ let response;
69
+ try {
70
+ response = await pf.search(q);
71
+ } catch {
72
+ return [];
73
+ }
74
+
75
+ const hits = await Promise.all(response.results.slice(0, 8).map((r) => r.data()));
76
+ const out = [];
77
+ const seen = new Set(); // dedupe by href
78
+ for (const hit of hits) {
79
+ const href = normalizeUrl(hit.url);
80
+ const title = hit.meta?.title || crumbsFromUrl(hit.url).pop() || href;
81
+ if (!seen.has(href)) {
82
+ seen.add(href);
83
+ out.push({
84
+ id: href,
85
+ group: 'Pages',
86
+ breadcrumb: crumbsFromUrl(hit.url),
87
+ title,
88
+ kind: 'page',
89
+ desc: stripHtml(hit.excerpt),
90
+ href,
91
+ });
92
+ }
93
+ // Heading-level matches → deep-linkable anchor rows. Skip the page's own
94
+ // top section (pagefind repeats it as a sub-result with the page title).
95
+ for (const sub of hit.sub_results || []) {
96
+ const subHref = normalizeUrl(sub.url);
97
+ if (seen.has(subHref) || !subHref.includes('#') || sub.title === title) continue;
98
+ seen.add(subHref);
99
+ out.push({
100
+ id: subHref,
101
+ group: 'Pages',
102
+ breadcrumb: [title],
103
+ title: sub.title || title,
104
+ kind: 'anchor',
105
+ desc: stripHtml(sub.excerpt),
106
+ href: subHref,
107
+ });
108
+ if (out.length >= 12) break;
109
+ }
110
+ if (out.length >= 12) break;
111
+ }
112
+ return out;
113
+ }
@@ -242,6 +242,22 @@
242
242
  "description": "Where the menu renders. Default \"header\"."
243
243
  }
244
244
  }
245
+ },
246
+ "assistant": {
247
+ "type": "object",
248
+ "additionalProperties": false,
249
+ "description": "Docs-site \"Ask AI\" backend. When apiBase is set, the published build wires the chatbot to the public docs-AI-assistant API; absent → the chatbot shows a canned demo answer (also in the dev preview).",
250
+ "required": ["apiBase"],
251
+ "properties": {
252
+ "apiBase": {
253
+ "type": "string",
254
+ "description": "Base URL of the Velu backend that serves /api/v1/public/ai-assistant/* (e.g. \"https://api.getvelu.com\"). Must be same-site with the docs (or send a SameSite=None visitor cookie) for the credentialed requests to work."
255
+ },
256
+ "host": {
257
+ "type": "string",
258
+ "description": "Override the tenant-resolution host sent as X-Velu-Site-Host. Defaults to the page's own host at runtime."
259
+ }
260
+ }
245
261
  }
246
262
  },
247
263
  "$defs": {
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { Routes, Route, useLocation, Link } from 'react-router-dom';
2
+ import { Routes, Route, useLocation, useNavigate, Link } from 'react-router-dom';
3
3
  import { MDXProvider } from '@mdx-js/react';
4
4
  // The project's pages + navigation, generated from velu.json by
5
5
  // vite-plugin-velu-site (see src/vite-plugin-velu-site.js). `pages` is
@@ -39,6 +39,8 @@ import {
39
39
  defaultMdxComponents,
40
40
  resolveIcon,
41
41
  Search,
42
+ pagefindSearch,
43
+ createDocsAssistant,
42
44
  Logo,
43
45
  SocialLinks,
44
46
  Tree,
@@ -719,6 +721,7 @@ function DocsPage() {
719
721
  // SSR and client compute these from the same pathname + the same
720
722
  // shared `resolve()`, so the render is identical (hydration-safe).
721
723
  const location = useLocation();
724
+ const navigate = useNavigate();
722
725
  const pathname = normalizeUrl(location.pathname);
723
726
  const entry = pages[pathname];
724
727
  const nav = React.useMemo(
@@ -726,6 +729,33 @@ function DocsPage() {
726
729
  [pathname],
727
730
  );
728
731
 
732
+ // Search source: Pagefind for content/excerpts, but resolve each result's
733
+ // breadcrumb from the real NAVIGATION (a page's nav group can differ from its
734
+ // URL path — e.g. essentials/markdown.mdx lives under the "Writing Content"
735
+ // group). Page rows show the path to the page; anchor rows include the page.
736
+ const searchDocs = React.useCallback(async (q) => {
737
+ const results = await pagefindSearch(q);
738
+ if (!Array.isArray(results)) return results;
739
+ return results.map((r) => {
740
+ const res = resolve(r.href.split('#')[0], navigation, pages);
741
+ if (!res?.breadcrumb?.length) return r;
742
+ const labels = res.breadcrumb.map((c) => c.label);
743
+ const crumbs = r.kind === 'anchor' ? labels : labels.slice(0, -1);
744
+ return crumbs.length ? { ...r, breadcrumb: crumbs } : r;
745
+ });
746
+ }, []);
747
+
748
+ // Ask-AI backend: wire the chatbot to the public docs-AI-assistant API only
749
+ // in a real build AND when an apiBase is configured (velu.json → assistant).
750
+ // Otherwise `ask` stays undefined and the Chatbot uses its canned demo answer
751
+ // (dev preview, or a site that hasn't enabled the assistant).
752
+ const assistant = React.useMemo(() => {
753
+ if (IS_DEV_PREVIEW) return null;
754
+ const apiBase = site.assistant?.apiBase;
755
+ if (!apiBase) return null;
756
+ return createDocsAssistant({ apiBase, host: site.assistant?.host });
757
+ }, []);
758
+
729
759
  const frontmatter = entry?.frontmatter ?? {};
730
760
  const PageComponent = entry?.Component ?? null;
731
761
  const pageToc = entry?.toc ?? [];
@@ -1116,7 +1146,12 @@ function DocsPage() {
1116
1146
  tabsTrailing={languageSwitcher || undefined}
1117
1147
  center={
1118
1148
  <Cluster space="var(--s-6)" align="center">
1119
- <Search style={{ inlineSize: '30ch' }} unavailable={IS_DEV_PREVIEW} />
1149
+ <Search
1150
+ style={{ inlineSize: '30ch' }}
1151
+ unavailable={IS_DEV_PREVIEW}
1152
+ search={IS_DEV_PREVIEW ? undefined : searchDocs}
1153
+ onSelect={(item) => item.href && navigate(item.href)}
1154
+ />
1120
1155
  {/* Ask AI talks to the deployed site's AI backend — hidden in
1121
1156
  the local dev preview where there's nothing to talk to. */}
1122
1157
  {!IS_DEV_PREVIEW && (
@@ -1276,6 +1311,11 @@ function DocsPage() {
1276
1311
  )}
1277
1312
  </Stack>
1278
1313
  )}
1314
+ {/* Hairline separating the top anchor links from the nav sections
1315
+ (faithful to the sidebar design's anchor↔sidebar divider). */}
1316
+ {anchors.length > 0 && (
1317
+ <div className="velu-docs-context-divider" aria-hidden="true" />
1318
+ )}
1279
1319
  {/* Only this region scrolls — the context zone above stays
1280
1320
  pinned. The up/down arrows overlay its top/bottom edges and
1281
1321
  appear (via the data-fade-* attrs the scroll handler sets)
@@ -1437,7 +1477,7 @@ function DocsPage() {
1437
1477
  })}
1438
1478
  </span>
1439
1479
  </button>
1440
- <div className="velu-docs-layout__article">
1480
+ <div className="velu-docs-layout__article" data-pagefind-body="">
1441
1481
  {/* Per-page agent/IDE action bar: the section eyebrow + a
1442
1482
  "Copy Page" split-button whose dropdown is driven by the
1443
1483
  Mintlify-compatible `contextual` config. Renders nothing
@@ -1581,6 +1621,8 @@ function DocsPage() {
1581
1621
  open={chatOpen}
1582
1622
  seedQuestion={chatQuestion}
1583
1623
  onClose={() => setChatOpen(false)}
1624
+ ask={assistant?.ask}
1625
+ onFeedback={assistant?.sendFeedback}
1584
1626
  />
1585
1627
  </div>
1586
1628
  );