@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.
- package/dist/cli.js +29 -29
- package/package.json +2 -1
- package/runtime/velu-ui/components/Chatbot.jsx +214 -50
- package/runtime/velu-ui/components/ContextMenu.jsx +1 -0
- package/runtime/velu-ui/components/Search.jsx +66 -17
- package/runtime/velu-ui/components/Sidebar.jsx +55 -0
- package/runtime/velu-ui/components/context-menu.css +4 -1
- package/runtime/velu-ui/components/docs-layout.css +34 -14
- package/runtime/velu-ui/components/page-header.css +1 -0
- package/runtime/velu-ui/components/sidebar.css +64 -3
- package/runtime/velu-ui/components/theme-toggle.css +1 -0
- package/runtime/velu-ui/index.js +2 -0
- package/runtime/velu-ui/lib/brand-icons.jsx +9 -8
- package/runtime/velu-ui/lib/docs-assistant.js +163 -0
- package/runtime/velu-ui/lib/pagefind.js +113 -0
- package/schema/velu.schema.json +16 -0
- package/src/runtime/App.jsx +45 -3
|
@@ -7,10 +7,51 @@
|
|
|
7
7
|
rotation, and centralized icon sizing/stroke. All values are tokens. */
|
|
8
8
|
|
|
9
9
|
.velu-sidebar {
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
package/runtime/velu-ui/index.js
CHANGED
|
@@ -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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
+
}
|
package/schema/velu.schema.json
CHANGED
|
@@ -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": {
|
package/src/runtime/App.jsx
CHANGED
|
@@ -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
|
|
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
|
);
|