@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.
- package/README.md +80 -80
- package/dist/cli.js +37 -37
- package/package.json +64 -64
- package/runtime/velu-ui/base.css +320 -320
- package/runtime/velu-ui/components/Accordion.jsx +64 -64
- package/runtime/velu-ui/components/ApiClient.jsx +207 -207
- package/runtime/velu-ui/components/ApiField.jsx +87 -87
- package/runtime/velu-ui/components/ApiPath.jsx +63 -63
- package/runtime/velu-ui/components/ApiReferencePage.jsx +384 -384
- package/runtime/velu-ui/components/ApiSamples.jsx +36 -36
- package/runtime/velu-ui/components/ApiSidebar.jsx +122 -122
- package/runtime/velu-ui/components/AskBar.jsx +71 -71
- package/runtime/velu-ui/components/Callout.jsx +114 -114
- package/runtime/velu-ui/components/Card.jsx +131 -131
- package/runtime/velu-ui/components/Chatbot.jsx +885 -885
- package/runtime/velu-ui/components/CodeBlock.jsx +375 -375
- package/runtime/velu-ui/components/Columns.jsx +56 -56
- package/runtime/velu-ui/components/ContextMenu.jsx +298 -273
- package/runtime/velu-ui/components/ErrorCard.jsx +138 -138
- package/runtime/velu-ui/components/Field.jsx +81 -81
- package/runtime/velu-ui/components/Image.jsx +163 -163
- package/runtime/velu-ui/components/Logo.jsx +31 -31
- package/runtime/velu-ui/components/MethodBadge.jsx +31 -31
- package/runtime/velu-ui/components/NavSelect.jsx +108 -108
- package/runtime/velu-ui/components/NotFound.jsx +63 -63
- package/runtime/velu-ui/components/PageFeedback.jsx +219 -219
- package/runtime/velu-ui/components/PageFooter.jsx +145 -145
- package/runtime/velu-ui/components/PageHeader.jsx +422 -422
- package/runtime/velu-ui/components/PageNav.jsx +77 -77
- package/runtime/velu-ui/components/PoweredBy.jsx +51 -51
- package/runtime/velu-ui/components/Prompt.jsx +115 -115
- package/runtime/velu-ui/components/Search.jsx +460 -460
- package/runtime/velu-ui/components/Sidebar.jsx +254 -254
- package/runtime/velu-ui/components/SocialLinks.jsx +90 -90
- package/runtime/velu-ui/components/Steps.jsx +65 -65
- package/runtime/velu-ui/components/ThemeToggle.jsx +48 -48
- package/runtime/velu-ui/components/Toc.jsx +537 -537
- package/runtime/velu-ui/components/TocBar.jsx +195 -195
- package/runtime/velu-ui/components/Tree.jsx +87 -87
- package/runtime/velu-ui/components/TryItBar.jsx +102 -102
- package/runtime/velu-ui/components/accordion.css +92 -92
- package/runtime/velu-ui/components/api-page.css +208 -208
- package/runtime/velu-ui/components/api.css +635 -635
- package/runtime/velu-ui/components/ask-bar.css +94 -94
- package/runtime/velu-ui/components/card.css +105 -105
- package/runtime/velu-ui/components/chatbot.css +622 -622
- package/runtime/velu-ui/components/code-block.css +263 -263
- package/runtime/velu-ui/components/context-menu.css +173 -173
- package/runtime/velu-ui/components/docs-layout.css +822 -822
- package/runtime/velu-ui/components/field.css +82 -82
- package/runtime/velu-ui/components/image.css +237 -237
- package/runtime/velu-ui/components/nav-select.css +157 -157
- package/runtime/velu-ui/components/not-found.css +94 -94
- package/runtime/velu-ui/components/page-feedback.css +241 -241
- package/runtime/velu-ui/components/page-footer.css +130 -130
- package/runtime/velu-ui/components/page-header.css +558 -558
- package/runtime/velu-ui/components/page-nav.css +50 -50
- package/runtime/velu-ui/components/powered-by.css +92 -92
- package/runtime/velu-ui/components/prompt.css +99 -99
- package/runtime/velu-ui/components/search.css +307 -307
- package/runtime/velu-ui/components/sidebar.css +205 -205
- package/runtime/velu-ui/components/steps.css +77 -77
- package/runtime/velu-ui/components/theme-toggle.css +102 -102
- package/runtime/velu-ui/components/toc-bar.css +234 -234
- package/runtime/velu-ui/components/tree.css +49 -49
- package/runtime/velu-ui/index.js +54 -54
- package/runtime/velu-ui/lib/api-send.js +92 -92
- package/runtime/velu-ui/lib/brand-icons.jsx +103 -103
- package/runtime/velu-ui/lib/component-schemas.js +100 -100
- package/runtime/velu-ui/lib/copyText.js +64 -64
- package/runtime/velu-ui/lib/docs-assistant.js +250 -250
- package/runtime/velu-ui/lib/lang-icons.jsx +147 -147
- package/runtime/velu-ui/lib/pagefind.js +113 -113
- package/runtime/velu-ui/lib/prism-langs.js +957 -957
- package/runtime/velu-ui/lib/prism-loader.js +74 -74
- package/runtime/velu-ui/lib/resolveIcon.jsx +29 -29
- package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -66
- package/runtime/velu-ui/mdx-components.jsx +105 -105
- package/runtime/velu-ui/primitives/Cluster.jsx +49 -49
- package/runtime/velu-ui/primitives/Stack.jsx +63 -63
- package/runtime/velu-ui/primitives/Switcher.jsx +57 -57
- package/runtime/velu-ui/primitives/stack.css +3 -3
- package/runtime/velu-ui/primitives/switcher.css +25 -25
- package/runtime/velu-ui/styles.css +46 -46
- package/runtime/velu-ui/tokens.css +4 -4
- package/schema/velu.schema.json +423 -423
- package/src/lib/extract-mdx-error.js +170 -170
- package/src/lib/issues.js +159 -159
- package/src/lib/known-components.js +34 -34
- package/src/navigation.js +443 -443
- package/src/runtime/App.jsx +1669 -1668
- package/src/runtime/ErrorBoundary.jsx +54 -54
- package/src/runtime/client-entry.jsx +22 -22
- package/src/runtime/server-entry.jsx +16 -16
- package/src/template.html +48 -48
- package/templates/starter/ai-tools/claude-code.mdx +26 -26
- package/templates/starter/ai-tools/cursor.mdx +17 -17
- package/templates/starter/api-reference/introduction.mdx +43 -43
- package/templates/starter/development.mdx +19 -19
- package/templates/starter/essentials/code.mdx +29 -29
- package/templates/starter/essentials/images.mdx +29 -29
- package/templates/starter/essentials/markdown.mdx +25 -25
- package/templates/starter/essentials/navigation.mdx +39 -39
- package/templates/starter/essentials/settings.mdx +30 -30
- package/templates/starter/favicon.svg +6 -6
- package/templates/starter/index.mdx +31 -31
- package/templates/starter/openapi.json +160 -160
- package/templates/starter/quickstart.mdx +31 -31
- package/templates/starter/velu.json +41 -41
package/src/runtime/App.jsx
CHANGED
|
@@ -1,1668 +1,1669 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { Routes, Route, useLocation, useNavigate, Link } from 'react-router-dom';
|
|
3
|
-
import { MDXProvider } from '@mdx-js/react';
|
|
4
|
-
// The project's pages + navigation, generated from velu.json by
|
|
5
|
-
// vite-plugin-velu-site (see src/vite-plugin-velu-site.js). `pages` is
|
|
6
|
-
// url → { Component, frontmatter, toc } (or { missing:true }).
|
|
7
|
-
import { pages, navigation, site } from 'virtual:velu-site';
|
|
8
|
-
import { resolve, normalizeUrl } from '../navigation.js';
|
|
9
|
-
import {
|
|
10
|
-
Stack,
|
|
11
|
-
Cluster,
|
|
12
|
-
ThemeToggle,
|
|
13
|
-
Sidebar,
|
|
14
|
-
NavSelect,
|
|
15
|
-
Toc,
|
|
16
|
-
TocBar,
|
|
17
|
-
ContextMenu,
|
|
18
|
-
Callout,
|
|
19
|
-
Accordion,
|
|
20
|
-
AccordionGroup,
|
|
21
|
-
Card,
|
|
22
|
-
CardGroup,
|
|
23
|
-
Switcher,
|
|
24
|
-
Image,
|
|
25
|
-
CodeBlock,
|
|
26
|
-
CodeGroup,
|
|
27
|
-
Columns,
|
|
28
|
-
Field,
|
|
29
|
-
Prompt,
|
|
30
|
-
Steps,
|
|
31
|
-
Step,
|
|
32
|
-
AskBar,
|
|
33
|
-
Chatbot,
|
|
34
|
-
PageFeedback,
|
|
35
|
-
PageNav,
|
|
36
|
-
PageFooter,
|
|
37
|
-
PageHeader,
|
|
38
|
-
PoweredBy,
|
|
39
|
-
defaultMdxComponents,
|
|
40
|
-
resolveIcon,
|
|
41
|
-
Search,
|
|
42
|
-
pagefindSearch,
|
|
43
|
-
createDocsAssistant,
|
|
44
|
-
Logo,
|
|
45
|
-
SocialLinks,
|
|
46
|
-
Tree,
|
|
47
|
-
Folder,
|
|
48
|
-
File,
|
|
49
|
-
TryItBar,
|
|
50
|
-
ApiClient,
|
|
51
|
-
ApiField,
|
|
52
|
-
ApiSidebar,
|
|
53
|
-
ApiSamples,
|
|
54
|
-
NotFound,
|
|
55
|
-
VeluMark,
|
|
56
|
-
} from 'velu-ui';
|
|
57
|
-
import { X, ChevronDown, ChevronUp } from 'lucide-react';
|
|
58
|
-
import ErrorBoundary from './ErrorBoundary.jsx';
|
|
59
|
-
|
|
60
|
-
// True under `velu dev`, false in the deployed production build (Vite injects
|
|
61
|
-
// this). AI features (Ask AI, the Ask-a-question bar) and full-text search run
|
|
62
|
-
// on the deployed site, so they're hidden / shown-as-unavailable in the local
|
|
63
|
-
// preview. Identical on SSR + client in each mode, so no hydration mismatch.
|
|
64
|
-
const IS_DEV_PREVIEW = import.meta.env.DEV;
|
|
65
|
-
|
|
66
|
-
const CALLOUT_TYPES = [
|
|
67
|
-
'note',
|
|
68
|
-
'warning',
|
|
69
|
-
'info',
|
|
70
|
-
'tip',
|
|
71
|
-
'check',
|
|
72
|
-
'danger',
|
|
73
|
-
'callout',
|
|
74
|
-
];
|
|
75
|
-
|
|
76
|
-
function CalloutGallery() {
|
|
77
|
-
return (
|
|
78
|
-
<Stack space="var(--s0)">
|
|
79
|
-
{CALLOUT_TYPES.map((t) => (
|
|
80
|
-
<Callout key={t} type={t}>
|
|
81
|
-
This is a {t}. This is a {t}. This is a {t}. This is a {t}.
|
|
82
|
-
</Callout>
|
|
83
|
-
))}
|
|
84
|
-
<p style={{ color: 'var(--muted-color)' }}>
|
|
85
|
-
Custom callouts can be built with a custom icon & color:
|
|
86
|
-
</p>
|
|
87
|
-
<Callout
|
|
88
|
-
icon="sparkles"
|
|
89
|
-
stroke="var(--accent-color)"
|
|
90
|
-
bg="color-mix(in srgb, var(--accent-color) 8%, transparent)"
|
|
91
|
-
>
|
|
92
|
-
A custom callout — lucide id <code>"sparkles"</code> with custom{' '}
|
|
93
|
-
<code>stroke</code> & <code>bg</code>.
|
|
94
|
-
</Callout>
|
|
95
|
-
|
|
96
|
-
<Callout type="info">
|
|
97
|
-
This is a multi-line callout. It keeps going and going so the text
|
|
98
|
-
wraps onto several lines — the icon sits on the first line, the text
|
|
99
|
-
wraps beside it, and every line after flows back under the icon (no
|
|
100
|
-
hanging indent). Resize the window to watch the wrapping reflow while
|
|
101
|
-
the icon stays put on line one. This is a multi-line callout. It keeps
|
|
102
|
-
going so the text wraps onto several lines.
|
|
103
|
-
</Callout>
|
|
104
|
-
</Stack>
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function AccordionDemo() {
|
|
109
|
-
return (
|
|
110
|
-
<Stack space="var(--s1)">
|
|
111
|
-
<Accordion title="This is an accordion">
|
|
112
|
-
This is content inside the accordion. Hover the header to see the
|
|
113
|
-
surface tint; the chevron rotates when expanded.
|
|
114
|
-
</Accordion>
|
|
115
|
-
<Accordion title="This is an accordion (open by default)" defaultOpen>
|
|
116
|
-
This one renders expanded — header takes the surface tint and a
|
|
117
|
-
divider separates it from this content.
|
|
118
|
-
</Accordion>
|
|
119
|
-
<AccordionGroup>
|
|
120
|
-
<Accordion title="This is an accordion">
|
|
121
|
-
Grouped items share one bordered box with dividers between them.
|
|
122
|
-
</Accordion>
|
|
123
|
-
<Accordion title="This is an accordion" defaultOpen>
|
|
124
|
-
This is content inside the accordion.
|
|
125
|
-
</Accordion>
|
|
126
|
-
<Accordion title="This is an accordion">
|
|
127
|
-
No per-item border or radius — the group owns the chrome.
|
|
128
|
-
</Accordion>
|
|
129
|
-
</AccordionGroup>
|
|
130
|
-
</Stack>
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function CardDemo() {
|
|
135
|
-
return (
|
|
136
|
-
<Stack space="var(--s2)">
|
|
137
|
-
<Card icon="align-justify" title="Card Title">
|
|
138
|
-
This is how you use a card with an icon and a link. (Cards are not
|
|
139
|
-
clickable — only the CTA card's link is.)
|
|
140
|
-
</Card>
|
|
141
|
-
|
|
142
|
-
<Card icon="align-justify" title="Card Title" horizontal>
|
|
143
|
-
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
|
|
144
|
-
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
|
|
145
|
-
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
|
|
146
|
-
commodo consequat.
|
|
147
|
-
</Card>
|
|
148
|
-
|
|
149
|
-
<Card
|
|
150
|
-
img="https://picsum.photos/seed/velu/1200/560"
|
|
151
|
-
imgAlt="Yosemite valley"
|
|
152
|
-
title="Card Title"
|
|
153
|
-
>
|
|
154
|
-
This is how you use a card with an icon. The image fills the container
|
|
155
|
-
and is clipped to the card radius.
|
|
156
|
-
</Card>
|
|
157
|
-
|
|
158
|
-
<Card
|
|
159
|
-
icon="align-justify"
|
|
160
|
-
title="Card Title"
|
|
161
|
-
cta={{ label: 'Click Here', href: '#cards' }}
|
|
162
|
-
>
|
|
163
|
-
This is how you use a card with an icon and a link. The "Click Here"
|
|
164
|
-
link below is the only clickable element.
|
|
165
|
-
</Card>
|
|
166
|
-
|
|
167
|
-
<CardGroup>
|
|
168
|
-
<Card icon="align-justify" title="Card Title">
|
|
169
|
-
This is how you use a card with an icon and a link.
|
|
170
|
-
</Card>
|
|
171
|
-
<Card icon="align-justify" title="Card Title">
|
|
172
|
-
This is how you use a card with an icon and a link.
|
|
173
|
-
</Card>
|
|
174
|
-
</CardGroup>
|
|
175
|
-
</Stack>
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function SwitcherBox({ children }) {
|
|
180
|
-
return (
|
|
181
|
-
<div
|
|
182
|
-
style={{
|
|
183
|
-
border: 'var(--border-width) solid var(--border-color)',
|
|
184
|
-
borderRadius: 'var(--radius-sm)',
|
|
185
|
-
padding: 'var(--s0) var(--s3)',
|
|
186
|
-
}}
|
|
187
|
-
>
|
|
188
|
-
{children}
|
|
189
|
-
</div>
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function SwitcherDemo() {
|
|
194
|
-
return (
|
|
195
|
-
<Stack space="var(--s1)">
|
|
196
|
-
<p style={{ color: 'var(--muted-color)' }}>
|
|
197
|
-
Resize the window — these flip between a row and a column at the
|
|
198
|
-
container threshold, with no media queries.
|
|
199
|
-
</p>
|
|
200
|
-
<Switcher space="var(--s0)" threshold="30rem">
|
|
201
|
-
<SwitcherBox>One</SwitcherBox>
|
|
202
|
-
<SwitcherBox>Two</SwitcherBox>
|
|
203
|
-
<SwitcherBox>Three</SwitcherBox>
|
|
204
|
-
</Switcher>
|
|
205
|
-
<p style={{ color: 'var(--muted-color)' }}>
|
|
206
|
-
With <code>limit={2}</code>: more than 2 items forces vertical
|
|
207
|
-
regardless of width.
|
|
208
|
-
</p>
|
|
209
|
-
<Switcher space="var(--s0)" threshold="30rem" limit={2}>
|
|
210
|
-
<SwitcherBox>One</SwitcherBox>
|
|
211
|
-
<SwitcherBox>Two</SwitcherBox>
|
|
212
|
-
<SwitcherBox>Three</SwitcherBox>
|
|
213
|
-
</Switcher>
|
|
214
|
-
</Stack>
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const DEMO_JS = `let greeting = function (name) {
|
|
219
|
-
console.log(\`Hello, \${name}!\`);
|
|
220
|
-
};`;
|
|
221
|
-
const DEMO_RB = `def greeting(name)
|
|
222
|
-
puts "Hello, #{name}!"
|
|
223
|
-
end`;
|
|
224
|
-
const DEMO_PY = `def greeting(name):
|
|
225
|
-
print(f"Hello, {name}!")`;
|
|
226
|
-
|
|
227
|
-
function CodeBlockDemo() {
|
|
228
|
-
return (
|
|
229
|
-
<Stack space="var(--s2)">
|
|
230
|
-
<p style={{ color: 'var(--muted-color)' }}>Code Group</p>
|
|
231
|
-
<CodeGroup>
|
|
232
|
-
<CodeBlock title="Javascript" language="javascript">
|
|
233
|
-
{DEMO_JS}
|
|
234
|
-
</CodeBlock>
|
|
235
|
-
<CodeBlock title="Ruby" language="ruby">
|
|
236
|
-
{DEMO_RB}
|
|
237
|
-
</CodeBlock>
|
|
238
|
-
<CodeBlock title="Python" language="python">
|
|
239
|
-
{DEMO_PY}
|
|
240
|
-
</CodeBlock>
|
|
241
|
-
</CodeGroup>
|
|
242
|
-
|
|
243
|
-
<p style={{ color: 'var(--muted-color)' }}>Code (with file name)</p>
|
|
244
|
-
<CodeBlock filename="index.js" language="javascript">
|
|
245
|
-
{DEMO_JS}
|
|
246
|
-
</CodeBlock>
|
|
247
|
-
|
|
248
|
-
<p style={{ color: 'var(--muted-color)' }}>
|
|
249
|
-
With line numbers (icon suppressed via <code>withIcon={'{false}'}</code>)
|
|
250
|
-
</p>
|
|
251
|
-
<CodeBlock
|
|
252
|
-
filename="index.js"
|
|
253
|
-
language="javascript"
|
|
254
|
-
withIcon={false}
|
|
255
|
-
lineNumbers
|
|
256
|
-
>
|
|
257
|
-
{DEMO_JS}
|
|
258
|
-
</CodeBlock>
|
|
259
|
-
|
|
260
|
-
<p style={{ color: 'var(--muted-color)' }}>
|
|
261
|
-
Line highlight — single line (<code>highlight="2"</code>)
|
|
262
|
-
</p>
|
|
263
|
-
<CodeBlock
|
|
264
|
-
filename="index.js"
|
|
265
|
-
language="javascript"
|
|
266
|
-
lineNumbers
|
|
267
|
-
highlight="2"
|
|
268
|
-
>
|
|
269
|
-
{DEMO_JS}
|
|
270
|
-
</CodeBlock>
|
|
271
|
-
|
|
272
|
-
<p style={{ color: 'var(--muted-color)' }}>
|
|
273
|
-
Line highlight — range + single (<code>highlight="1-2,4"</code>)
|
|
274
|
-
</p>
|
|
275
|
-
<CodeBlock
|
|
276
|
-
filename="server.js"
|
|
277
|
-
language="javascript"
|
|
278
|
-
lineNumbers
|
|
279
|
-
highlight="1-2,4"
|
|
280
|
-
>
|
|
281
|
-
{`const express = require('express');
|
|
282
|
-
const app = express();
|
|
283
|
-
|
|
284
|
-
app.get('/', (req, res) => res.send('Hello'));
|
|
285
|
-
|
|
286
|
-
app.listen(3000);`}
|
|
287
|
-
</CodeBlock>
|
|
288
|
-
</Stack>
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function ApiDemo() {
|
|
293
|
-
const path = '/project/preview/{projectId}';
|
|
294
|
-
const curl = `curl --request POST \\
|
|
295
|
-
--url https://api.mintlify.com/v1/project/update/{projectId} \\
|
|
296
|
-
--header 'Authorization: Bearer <token>'`;
|
|
297
|
-
return (
|
|
298
|
-
<Stack space="var(--s2)">
|
|
299
|
-
<p style={{ color: 'var(--muted-color)' }}>Try-it bar</p>
|
|
300
|
-
<Stack space="var(--s-1)">
|
|
301
|
-
<TryItBar method="POST" path={path} />
|
|
302
|
-
<TryItBar method="GET" path={path} />
|
|
303
|
-
<TryItBar method="PUT" path={path} />
|
|
304
|
-
<TryItBar method="DELETE" path={path} />
|
|
305
|
-
<TryItBar method="PATCH" path={path} />
|
|
306
|
-
</Stack>
|
|
307
|
-
|
|
308
|
-
<p style={{ color: 'var(--muted-color)' }}>API sidebar</p>
|
|
309
|
-
<ApiSidebar
|
|
310
|
-
activeHref="/api/status"
|
|
311
|
-
sections={[
|
|
312
|
-
{
|
|
313
|
-
title: 'Admin',
|
|
314
|
-
icon: 'rocket',
|
|
315
|
-
endpoints: [
|
|
316
|
-
{ method: 'POST', label: 'Trigger', href: '/api/trigger' },
|
|
317
|
-
{
|
|
318
|
-
method: 'GET',
|
|
319
|
-
label: 'Get deployment-status',
|
|
320
|
-
href: '/api/status',
|
|
321
|
-
},
|
|
322
|
-
{
|
|
323
|
-
method: 'DELETE',
|
|
324
|
-
label: 'Trigger Preview deployment',
|
|
325
|
-
href: '/api/preview',
|
|
326
|
-
},
|
|
327
|
-
],
|
|
328
|
-
},
|
|
329
|
-
]}
|
|
330
|
-
/>
|
|
331
|
-
|
|
332
|
-
<p style={{ color: 'var(--muted-color)' }}>API Client</p>
|
|
333
|
-
{/* Only the ApiClient breaks out of the 46rem prose column — the
|
|
334
|
-
TryItBars above stay at the normal column width. clamp keeps
|
|
335
|
-
it ≥ 100% (so it never collapses when the main-area calc goes
|
|
336
|
-
negative on narrow screens) and ≤ 64rem. */}
|
|
337
|
-
<div
|
|
338
|
-
style={{
|
|
339
|
-
width:
|
|
340
|
-
'clamp(100%, calc(100vw - 240px - 280px - var(--s4) * 2), 64rem)',
|
|
341
|
-
}}
|
|
342
|
-
>
|
|
343
|
-
<ApiClient
|
|
344
|
-
method="DELETE"
|
|
345
|
-
label="Trigger Delete"
|
|
346
|
-
path={path}
|
|
347
|
-
onClose={() => {}}
|
|
348
|
-
description="Trigger a documentation deployment programmatically to publish updates outside of Git workflows."
|
|
349
|
-
aside={
|
|
350
|
-
<Stack space="var(--s1)">
|
|
351
|
-
<Callout type="danger">401 — Unauthorized</Callout>
|
|
352
|
-
<CodeGroup>
|
|
353
|
-
<CodeBlock title="Response" language="json">
|
|
354
|
-
{`{\n "status": 401,\n "error": "Unauthorized"\n}`}
|
|
355
|
-
</CodeBlock>
|
|
356
|
-
<CodeBlock title="Headers" language="http">
|
|
357
|
-
{`HTTP/1.1 401 Unauthorized
|
|
358
|
-
date: Mon, 27 Apr 2026 12:00:55 GMT
|
|
359
|
-
content-type: application/json; charset=utf-8
|
|
360
|
-
content-length: 24
|
|
361
|
-
connection: close
|
|
362
|
-
x-powered-by: Express
|
|
363
|
-
access-control-allow-origin: *`}
|
|
364
|
-
</CodeBlock>
|
|
365
|
-
</CodeGroup>
|
|
366
|
-
<CodeGroup>
|
|
367
|
-
<CodeBlock title="Curl" language="bash">
|
|
368
|
-
{curl}
|
|
369
|
-
</CodeBlock>
|
|
370
|
-
<CodeBlock title="Ruby" language="ruby">
|
|
371
|
-
{`require 'net/http'
|
|
372
|
-
uri = URI('https://api.mintlify.com/v1/project/update/<projectId>')
|
|
373
|
-
req = Net::HTTP::Post.new(uri)
|
|
374
|
-
req['Authorization'] = 'Bearer <token>'
|
|
375
|
-
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }`}
|
|
376
|
-
</CodeBlock>
|
|
377
|
-
<CodeBlock title="Python" language="python">
|
|
378
|
-
{`import requests
|
|
379
|
-
requests.post(
|
|
380
|
-
'https://api.mintlify.com/v1/project/update/<projectId>',
|
|
381
|
-
headers={'Authorization': 'Bearer <token>'},
|
|
382
|
-
)`}
|
|
383
|
-
</CodeBlock>
|
|
384
|
-
</CodeGroup>
|
|
385
|
-
<CodeGroup>
|
|
386
|
-
<CodeBlock title="202" language="json">
|
|
387
|
-
{`{\n "statusId": "<string>"\n}`}
|
|
388
|
-
</CodeBlock>
|
|
389
|
-
<CodeBlock title="200" language="json">
|
|
390
|
-
{`{\n "statusId": "<string>",\n "completedAt": "2026-04-27T12:00:55Z"\n}`}
|
|
391
|
-
</CodeBlock>
|
|
392
|
-
</CodeGroup>
|
|
393
|
-
</Stack>
|
|
394
|
-
}
|
|
395
|
-
>
|
|
396
|
-
<AccordionGroup>
|
|
397
|
-
<Accordion title="Authorization" defaultOpen>
|
|
398
|
-
<Stack space="var(--s1)">
|
|
399
|
-
<ApiField
|
|
400
|
-
name="Authorization"
|
|
401
|
-
type="string"
|
|
402
|
-
required
|
|
403
|
-
prefix="Bearer"
|
|
404
|
-
>
|
|
405
|
-
The Authorization header expects a Bearer token. Use an
|
|
406
|
-
admin API key (prefixed with <code>mint_</code>).
|
|
407
|
-
</ApiField>
|
|
408
|
-
</Stack>
|
|
409
|
-
</Accordion>
|
|
410
|
-
<Accordion title="Path" defaultOpen>
|
|
411
|
-
<Stack space="var(--s1)">
|
|
412
|
-
<ApiField name="projectId" type="string" required>
|
|
413
|
-
Your project ID. Copy it from the API keys page.
|
|
414
|
-
</ApiField>
|
|
415
|
-
<ApiField name="bodyId" type="string" required>
|
|
416
|
-
Your body ID. Copy it from the API keys page.
|
|
417
|
-
</ApiField>
|
|
418
|
-
</Stack>
|
|
419
|
-
</Accordion>
|
|
420
|
-
<Accordion title="Query">
|
|
421
|
-
<Stack space="var(--s1)">
|
|
422
|
-
<ApiField name="branch" type="string">
|
|
423
|
-
Git branch to deploy. Defaults to the production branch.
|
|
424
|
-
</ApiField>
|
|
425
|
-
<ApiField name="dryRun" type="boolean" default="false">
|
|
426
|
-
When true, validates the deployment without publishing.
|
|
427
|
-
</ApiField>
|
|
428
|
-
</Stack>
|
|
429
|
-
</Accordion>
|
|
430
|
-
<Accordion title="Headers">
|
|
431
|
-
<Stack space="var(--s1)">
|
|
432
|
-
<ApiField name="X-Idempotency-Key" type="string">
|
|
433
|
-
Optional unique key so retried requests don't trigger
|
|
434
|
-
duplicate deployments.
|
|
435
|
-
</ApiField>
|
|
436
|
-
</Stack>
|
|
437
|
-
</Accordion>
|
|
438
|
-
<Accordion title="Body">
|
|
439
|
-
<Stack space="var(--s1)">
|
|
440
|
-
<ApiField name="message" type="string">
|
|
441
|
-
Deployment message shown in the activity log.
|
|
442
|
-
</ApiField>
|
|
443
|
-
<ApiField name="notify" type="boolean" default="true">
|
|
444
|
-
Whether to notify when the deployment completes.
|
|
445
|
-
</ApiField>
|
|
446
|
-
</Stack>
|
|
447
|
-
</Accordion>
|
|
448
|
-
</AccordionGroup>
|
|
449
|
-
</ApiClient>
|
|
450
|
-
</div>
|
|
451
|
-
</Stack>
|
|
452
|
-
);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function TreeDemo() {
|
|
456
|
-
return (
|
|
457
|
-
<Tree>
|
|
458
|
-
<Folder name="app">
|
|
459
|
-
<File name="layout.tsx" />
|
|
460
|
-
<File name="page.tsx" />
|
|
461
|
-
<Folder name="api" defaultOpen={false}>
|
|
462
|
-
<File name="route.ts" />
|
|
463
|
-
</Folder>
|
|
464
|
-
</Folder>
|
|
465
|
-
<File name="package.json" />
|
|
466
|
-
</Tree>
|
|
467
|
-
);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function StepsDemo() {
|
|
471
|
-
return (
|
|
472
|
-
<Steps>
|
|
473
|
-
<Step title="Install the CLI">Run the install script.</Step>
|
|
474
|
-
<Step title="Bootstrap a new project">
|
|
475
|
-
Scaffold a fresh velu project with a single command, then drop in the
|
|
476
|
-
starter MDX so you can begin editing right away. The CLI prompts for a
|
|
477
|
-
project name and target directory, copies the template files, and
|
|
478
|
-
installs dependencies in the background while you keep working.
|
|
479
|
-
</Step>
|
|
480
|
-
<Step title="Add your first page">
|
|
481
|
-
Drop an MDX file into the <code>content/</code> directory.
|
|
482
|
-
</Step>
|
|
483
|
-
<Step title="Configure navigation">
|
|
484
|
-
Edit <code>velu.config.js</code> and add your page paths under the
|
|
485
|
-
sidebar tree. Nested groups are supported, every item can carry an
|
|
486
|
-
icon, and external links are flagged automatically. Save the file and
|
|
487
|
-
the dev server hot-reloads with the new structure.
|
|
488
|
-
</Step>
|
|
489
|
-
<Step title="Preview locally">
|
|
490
|
-
Run <code>velu dev</code> — the local preview opens at
|
|
491
|
-
<code> localhost:5173</code> with SSR + HMR. Edits to any MDX or React
|
|
492
|
-
component reflect instantly. The right-rail TOC is generated from the
|
|
493
|
-
page's headings; the comet trail follows scroll.
|
|
494
|
-
</Step>
|
|
495
|
-
<Step title="Push and ship">Open a PR. Done.</Step>
|
|
496
|
-
</Steps>
|
|
497
|
-
);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function PromptDemo() {
|
|
501
|
-
return (
|
|
502
|
-
<Prompt title="Generate clear, concise documentation." openInCursor>
|
|
503
|
-
{`You are a **technical writing assistant**. Write documentation that is clear, accurate, and concise.
|
|
504
|
-
- Use second-person voice
|
|
505
|
-
- Avoid jargon
|
|
506
|
-
- Lead with what the reader can do, not how it works internally
|
|
507
|
-
- Code blocks use the project's language conventions
|
|
508
|
-
- Every example must be runnable as-is`}
|
|
509
|
-
</Prompt>
|
|
510
|
-
);
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
function FieldDemo() {
|
|
514
|
-
const desc =
|
|
515
|
-
'An example of a parameter field. An example of a parameter field. An example of a parameter field. An example of a parameter field. An example of a parameter field. An example of a parameter field';
|
|
516
|
-
return (
|
|
517
|
-
<div>
|
|
518
|
-
<Field
|
|
519
|
-
name="param"
|
|
520
|
-
pre="pre"
|
|
521
|
-
type="string"
|
|
522
|
-
required
|
|
523
|
-
default={3}
|
|
524
|
-
post="post"
|
|
525
|
-
>
|
|
526
|
-
{desc}
|
|
527
|
-
</Field>
|
|
528
|
-
<Field
|
|
529
|
-
name="param"
|
|
530
|
-
pre="pre"
|
|
531
|
-
type="string"
|
|
532
|
-
required
|
|
533
|
-
default={3}
|
|
534
|
-
post="post"
|
|
535
|
-
>
|
|
536
|
-
{desc}
|
|
537
|
-
</Field>
|
|
538
|
-
<Field
|
|
539
|
-
name="param"
|
|
540
|
-
pre="pre"
|
|
541
|
-
type="string"
|
|
542
|
-
required
|
|
543
|
-
default={3}
|
|
544
|
-
post="post"
|
|
545
|
-
>
|
|
546
|
-
{desc}
|
|
547
|
-
</Field>
|
|
548
|
-
</div>
|
|
549
|
-
);
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
function ColumnsDemo() {
|
|
553
|
-
return (
|
|
554
|
-
<Stack space="var(--s2)">
|
|
555
|
-
<p style={{ color: 'var(--muted-color)' }}>
|
|
556
|
-
Two equal columns (<code>cols={2}</code>) — collapses to a
|
|
557
|
-
single stack when items would fall below their per-item min width.
|
|
558
|
-
</p>
|
|
559
|
-
<Columns cols={2}>
|
|
560
|
-
<Card
|
|
561
|
-
icon="align-justify"
|
|
562
|
-
title="Card Title"
|
|
563
|
-
cta={{ label: 'Click Here', href: '#columns' }}
|
|
564
|
-
>
|
|
565
|
-
This is how you use a card with an icon and a link. Clicking on
|
|
566
|
-
this card brings you to the Columns page.
|
|
567
|
-
</Card>
|
|
568
|
-
<Card
|
|
569
|
-
icon="align-justify"
|
|
570
|
-
title="Card Title"
|
|
571
|
-
cta={{ label: 'Click Here', href: '#columns' }}
|
|
572
|
-
>
|
|
573
|
-
This is how you use a card with an icon and a link. Clicking on
|
|
574
|
-
this card brings you to the Columns page.
|
|
575
|
-
</Card>
|
|
576
|
-
</Columns>
|
|
577
|
-
|
|
578
|
-
<p style={{ color: 'var(--muted-color)' }}>
|
|
579
|
-
Heterogeneous children — a Card alongside a CodeBlock.
|
|
580
|
-
</p>
|
|
581
|
-
<Columns cols={2}>
|
|
582
|
-
<Card icon="sparkles" title="With a card">
|
|
583
|
-
Cards, callouts, code, images — anything composes in a column.
|
|
584
|
-
</Card>
|
|
585
|
-
<CodeBlock filename="hello.js" language="javascript">
|
|
586
|
-
{DEMO_JS}
|
|
587
|
-
</CodeBlock>
|
|
588
|
-
</Columns>
|
|
589
|
-
|
|
590
|
-
<p style={{ color: 'var(--muted-color)' }}>
|
|
591
|
-
Three columns (<code>cols={3}</code>) with a stacked column
|
|
592
|
-
in the middle.
|
|
593
|
-
</p>
|
|
594
|
-
<Columns cols={3}>
|
|
595
|
-
<Card icon="rocket" title="Left">
|
|
596
|
-
Single card on the left.
|
|
597
|
-
</Card>
|
|
598
|
-
<Stack space="var(--s0)">
|
|
599
|
-
<Card icon="book" title="Top">
|
|
600
|
-
Two cards stacked via Stack inside one column.
|
|
601
|
-
</Card>
|
|
602
|
-
<Card icon="brain" title="Bottom">
|
|
603
|
-
Stack lets a column carry heterogeneous, multi-item content.
|
|
604
|
-
</Card>
|
|
605
|
-
</Stack>
|
|
606
|
-
<Card icon="zap" title="Right">
|
|
607
|
-
Single card on the right.
|
|
608
|
-
</Card>
|
|
609
|
-
</Columns>
|
|
610
|
-
</Stack>
|
|
611
|
-
);
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
const DEMO_IMG = 'https://picsum.photos/seed/velu-yosemite/1200/600';
|
|
615
|
-
const DEMO_SHOT = 'https://picsum.photos/seed/velu-shot/600/1000';
|
|
616
|
-
|
|
617
|
-
function ImageDemo() {
|
|
618
|
-
return (
|
|
619
|
-
<Stack space="var(--s2)">
|
|
620
|
-
<p style={{ color: 'var(--muted-color)' }}>Image</p>
|
|
621
|
-
<Image src={DEMO_IMG} alt="" />
|
|
622
|
-
|
|
623
|
-
<p style={{ color: 'var(--muted-color)' }}>
|
|
624
|
-
Image with caption (caption can be markdown later)
|
|
625
|
-
</p>
|
|
626
|
-
<Image src={DEMO_IMG} alt="" caption="Caption for this image" />
|
|
627
|
-
|
|
628
|
-
<p style={{ color: 'var(--muted-color)' }}>Image with window chrome</p>
|
|
629
|
-
<Image src={DEMO_IMG} alt="" chrome="window" />
|
|
630
|
-
|
|
631
|
-
<p style={{ color: 'var(--muted-color)' }}>Image with display frame</p>
|
|
632
|
-
<Image src={DEMO_SHOT} alt="" chrome="frame" caption="Caption can also be added" />
|
|
633
|
-
</Stack>
|
|
634
|
-
);
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
const RouterLink = ({ href, ...rest }) => <Link to={href} {...rest} />;
|
|
638
|
-
|
|
639
|
-
// Flatten a nested TOC tree to {id, label, depth} for scroll-spy.
|
|
640
|
-
function flat(nodes, depth = 0, out = []) {
|
|
641
|
-
for (const n of nodes) {
|
|
642
|
-
out.push({ id: n.id, label: n.label, depth });
|
|
643
|
-
if (n.children) flat(n.children, depth + 1, out);
|
|
644
|
-
}
|
|
645
|
-
return out;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
// Scroll-spy: active = the section whose top last crossed the trigger line.
|
|
649
|
-
/**
|
|
650
|
-
* useScrollSpy — tracks the section currently in view, with an
|
|
651
|
-
* explicit "lock" override for click-driven scrolls.
|
|
652
|
-
*
|
|
653
|
-
* Without the lock, clicking a TOC item triggers a smooth-scroll
|
|
654
|
-
* animation; during the ~500ms animation the scroll listener fires
|
|
655
|
-
* many times with the page at intermediate positions, and the
|
|
656
|
-
* "closest section" answer flickers across multiple sections
|
|
657
|
-
* before settling — visible as the TocBar label briefly showing
|
|
658
|
-
* the wrong heading. The lock blocks spy updates for a configurable
|
|
659
|
-
* window so the click's intended `activeId` survives the animation.
|
|
660
|
-
*
|
|
661
|
-
* Returns `[activeId, setActive]` where `setActive(id)` writes the
|
|
662
|
-
* active id directly AND locks the spy for ~800ms.
|
|
663
|
-
*/
|
|
664
|
-
function useScrollSpy(ids) {
|
|
665
|
-
const [activeId, setActiveId] = React.useState(ids[0]);
|
|
666
|
-
const lockUntilRef = React.useRef(0);
|
|
667
|
-
React.useEffect(() => {
|
|
668
|
-
const onScroll = () => {
|
|
669
|
-
if (Date.now() < lockUntilRef.current) return;
|
|
670
|
-
// Trigger line reads from --velu-scroll-offset (published by
|
|
671
|
-
// App.jsx as header + tocbar-toggle + 1rem). Same offset CSS
|
|
672
|
-
// uses for scroll-margin, so a heading freshly scrolled into
|
|
673
|
-
// view sits exactly at the trigger. Semantics: pick the
|
|
674
|
-
// section whose top is CLOSEST to the trigger; tie-break
|
|
675
|
-
// favours later sections in document order.
|
|
676
|
-
const trigger =
|
|
677
|
-
parseFloat(
|
|
678
|
-
getComputedStyle(document.documentElement).getPropertyValue(
|
|
679
|
-
'--velu-scroll-offset',
|
|
680
|
-
),
|
|
681
|
-
) || 0;
|
|
682
|
-
const vh = window.innerHeight;
|
|
683
|
-
let best = ids[0];
|
|
684
|
-
let bestDist = Infinity;
|
|
685
|
-
for (const id of ids) {
|
|
686
|
-
const node = document.getElementById(id);
|
|
687
|
-
if (!node) continue;
|
|
688
|
-
const top = node.getBoundingClientRect().top;
|
|
689
|
-
if (top >= vh) continue;
|
|
690
|
-
const dist = Math.abs(top - trigger);
|
|
691
|
-
if (dist <= bestDist) {
|
|
692
|
-
bestDist = dist;
|
|
693
|
-
best = id;
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
setActiveId(best);
|
|
697
|
-
};
|
|
698
|
-
window.addEventListener('scroll', onScroll, { passive: true });
|
|
699
|
-
onScroll();
|
|
700
|
-
return () => window.removeEventListener('scroll', onScroll);
|
|
701
|
-
}, [ids]);
|
|
702
|
-
|
|
703
|
-
const setActive = React.useCallback((id) => {
|
|
704
|
-
setActiveId(id);
|
|
705
|
-
// ~800ms covers the default smooth-scroll animation duration.
|
|
706
|
-
lockUntilRef.current = Date.now() + 800;
|
|
707
|
-
}, []);
|
|
708
|
-
|
|
709
|
-
return [activeId, setActive];
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// Slugger used for the frontmatter-derived h1 id — same library as
|
|
713
|
-
// extract-toc + rehype-slug, so the id we render in the DOM matches
|
|
714
|
-
// what the TOC's `pageId` will compute. (One instance per page render
|
|
715
|
-
// is fine; the slugger is stateful only across calls on the SAME
|
|
716
|
-
// instance.)
|
|
717
|
-
import GithubSlugger from 'github-slugger';
|
|
718
|
-
|
|
719
|
-
function DocsPage() {
|
|
720
|
-
// Resolve the current route → page entry + navigation context. Both
|
|
721
|
-
// SSR and client compute these from the same pathname + the same
|
|
722
|
-
// shared `resolve()`, so the render is identical (hydration-safe).
|
|
723
|
-
const location = useLocation();
|
|
724
|
-
const navigate = useNavigate();
|
|
725
|
-
const pathname = normalizeUrl(location.pathname);
|
|
726
|
-
const entry = pages[pathname];
|
|
727
|
-
// `navigation`/`pages` are module constants in dev/build (the deps never
|
|
728
|
-
// change there); the instant preview's virtual-site substitute reassigns
|
|
729
|
-
// the `pages` live binding as pages finish compiling, so listing them re-
|
|
730
|
-
// resolves nav (sidebar labels, breadcrumb, prev/next) as titles arrive.
|
|
731
|
-
const nav = React.useMemo(
|
|
732
|
-
() => resolve(pathname, navigation, pages),
|
|
733
|
-
[pathname, navigation, pages],
|
|
734
|
-
);
|
|
735
|
-
|
|
736
|
-
// Search source: Pagefind for content/excerpts, but resolve each result's
|
|
737
|
-
// breadcrumb from the real NAVIGATION (a page's nav group can differ from its
|
|
738
|
-
// URL path — e.g. essentials/markdown.mdx lives under the "Writing Content"
|
|
739
|
-
// group). Page rows show the path to the page; anchor rows include the page.
|
|
740
|
-
const searchDocs = React.useCallback(async (q) => {
|
|
741
|
-
const results = await pagefindSearch(q);
|
|
742
|
-
if (!Array.isArray(results)) return results;
|
|
743
|
-
return results.map((r) => {
|
|
744
|
-
const res = resolve(r.href.split('#')[0], navigation, pages);
|
|
745
|
-
if (!res?.breadcrumb?.length) return r;
|
|
746
|
-
const labels = res.breadcrumb.map((c) => c.label);
|
|
747
|
-
const crumbs = r.kind === 'anchor' ? labels : labels.slice(0, -1);
|
|
748
|
-
return crumbs.length ? { ...r, breadcrumb: crumbs } : r;
|
|
749
|
-
});
|
|
750
|
-
}, []);
|
|
751
|
-
|
|
752
|
-
// Ask-AI backend: wire the chatbot to the public docs-AI-assistant API only
|
|
753
|
-
// in a real build AND when an apiBase is configured (velu.json → assistant).
|
|
754
|
-
// Otherwise `ask` stays undefined and the Chatbot uses its canned demo answer
|
|
755
|
-
// (dev preview, or a site that hasn't enabled the assistant).
|
|
756
|
-
const assistant = React.useMemo(() => {
|
|
757
|
-
if (IS_DEV_PREVIEW) return null;
|
|
758
|
-
const apiBase = site.assistant?.apiBase;
|
|
759
|
-
if (!apiBase) return null;
|
|
760
|
-
return createDocsAssistant({ apiBase, host: site.assistant?.host });
|
|
761
|
-
}, []);
|
|
762
|
-
|
|
763
|
-
const frontmatter = entry?.frontmatter ?? {};
|
|
764
|
-
const PageComponent = entry?.Component ?? null;
|
|
765
|
-
const pageToc = entry?.toc ?? [];
|
|
766
|
-
// No renderable page for this route → show the 404 page (a clean centered
|
|
767
|
-
// takeover: header + footer stay, the docs sidebar/TOC are hidden).
|
|
768
|
-
const isNotFound = !PageComponent;
|
|
769
|
-
|
|
770
|
-
// Switcher option sets (only render a switcher when an axis has >1
|
|
771
|
-
// option). Anchors are pinned sidebar links shown in the context zone.
|
|
772
|
-
const productOptions = nav?.products ?? [];
|
|
773
|
-
const versionOptions = nav?.versions ?? [];
|
|
774
|
-
const languageOptions = nav?.languages ?? [];
|
|
775
|
-
const anchors = nav?.anchors ?? [];
|
|
776
|
-
const versionSwitcher = versionOptions.length > 1 && (
|
|
777
|
-
<NavSelect
|
|
778
|
-
size="sm"
|
|
779
|
-
value={nav.activeVersion}
|
|
780
|
-
options={versionOptions}
|
|
781
|
-
linkComponent={RouterLink}
|
|
782
|
-
ariaLabel="Version"
|
|
783
|
-
/>
|
|
784
|
-
);
|
|
785
|
-
const languageSwitcher = languageOptions.length > 1 && (
|
|
786
|
-
<NavSelect
|
|
787
|
-
bare
|
|
788
|
-
icon="globe"
|
|
789
|
-
value={nav.activeLanguage}
|
|
790
|
-
valueCode={nav.activeLanguageCode}
|
|
791
|
-
options={languageOptions}
|
|
792
|
-
linkComponent={RouterLink}
|
|
793
|
-
ariaLabel="Language"
|
|
794
|
-
/>
|
|
795
|
-
);
|
|
796
|
-
const productSwitcher = productOptions.length > 1 && (
|
|
797
|
-
<NavSelect
|
|
798
|
-
value={nav.activeProduct}
|
|
799
|
-
options={productOptions}
|
|
800
|
-
linkComponent={RouterLink}
|
|
801
|
-
ariaLabel="Product"
|
|
802
|
-
/>
|
|
803
|
-
);
|
|
804
|
-
|
|
805
|
-
// Keep document.title / meta description / <html lang> in sync on client-side
|
|
806
|
-
// navigation. The HTML is SSR'd with a per-page (build) or generic (dev) title;
|
|
807
|
-
// without this the tab title would stay frozen on the first page as you
|
|
808
|
-
// navigate the SPA, and JS-executing crawlers would read the stale value.
|
|
809
|
-
React.useEffect(() => {
|
|
810
|
-
const t = frontmatter.title;
|
|
811
|
-
document.title = isNotFound
|
|
812
|
-
? `Page not found - ${site.name}`
|
|
813
|
-
: t && t !== site.name
|
|
814
|
-
? `${t} - ${site.name}`
|
|
815
|
-
: site.name;
|
|
816
|
-
let descTag = document.querySelector('meta[name="description"]');
|
|
817
|
-
if (!descTag) {
|
|
818
|
-
descTag = document.createElement('meta');
|
|
819
|
-
descTag.setAttribute('name', 'description');
|
|
820
|
-
document.head.appendChild(descTag);
|
|
821
|
-
}
|
|
822
|
-
descTag.setAttribute('content', frontmatter.description || '');
|
|
823
|
-
document.documentElement.lang = nav?.activeLanguageCode || 'en';
|
|
824
|
-
}, [pathname, isNotFound, frontmatter.title, frontmatter.description, nav?.activeLanguageCode]);
|
|
825
|
-
|
|
826
|
-
// Frontmatter title needs an id so scroll-spy + click-to-scroll work
|
|
827
|
-
// against it like any other heading.
|
|
828
|
-
const pageId = React.useMemo(() => {
|
|
829
|
-
if (!frontmatter.title) return null;
|
|
830
|
-
return new GithubSlugger().slug(frontmatter.title);
|
|
831
|
-
}, [frontmatter.title]);
|
|
832
|
-
|
|
833
|
-
// Combined TOC: the frontmatter title is the page's top-level entry,
|
|
834
|
-
// and the MDX-derived headings (h2s) become its children. Without this,
|
|
835
|
-
// the right rail starts from the first h2 — orphaning the page title
|
|
836
|
-
// that sits visibly above the article.
|
|
837
|
-
const toc = React.useMemo(() => {
|
|
838
|
-
if (!frontmatter.title || !pageId) return pageToc;
|
|
839
|
-
return [{ id: pageId, label: frontmatter.title, children: pageToc }];
|
|
840
|
-
}, [pageId, frontmatter.title, pageToc]);
|
|
841
|
-
|
|
842
|
-
// Flat list of section ids for scroll-spy — sourced from the same
|
|
843
|
-
// nested TOC tree we pass to the right-rail <Toc>, so spy + render
|
|
844
|
-
// can't drift out of sync with the MDX content.
|
|
845
|
-
const ids = React.useMemo(() => flat(toc).map((s) => s.id), [toc]);
|
|
846
|
-
const [activeId, setActive] = useScrollSpy(ids);
|
|
847
|
-
|
|
848
|
-
// Ask-AI chatbot: opens (slides in from the side) when a question is
|
|
849
|
-
// submitted in the AskBar. While it's open the TOC is hidden.
|
|
850
|
-
const [chatOpen, setChatOpen] = React.useState(false);
|
|
851
|
-
const [chatQuestion, setChatQuestion] = React.useState('');
|
|
852
|
-
|
|
853
|
-
// Left sidebar open/closed (narrow widths) — chevron toggle. Defaults
|
|
854
|
-
// open per design; safe to default true since the narrow-width
|
|
855
|
-
// sidebar is in-flow and doesn't obscure content.
|
|
856
|
-
const [sidebarOpen, setSidebarOpen] = React.useState(true);
|
|
857
|
-
// Mobile drawer open/closed (< 640px) — burger / breadcrumb / X /
|
|
858
|
-
// scrim drive this. Defaults closed so refreshing the page at
|
|
859
|
-
// mobile doesn't surface the drawer over the article. Independent
|
|
860
|
-
// of sidebarOpen so neither breakpoint's default leaks into the
|
|
861
|
-
// other.
|
|
862
|
-
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
|
863
|
-
|
|
864
|
-
// Drawer's nav dropdown — list of section links (Home/Docs/Blog),
|
|
865
|
-
// mirrors the desktop tabs. Click-outside + Escape close.
|
|
866
|
-
const [navOpen, setNavOpen] = React.useState(false);
|
|
867
|
-
const navRef = React.useRef(null);
|
|
868
|
-
React.useEffect(() => {
|
|
869
|
-
if (!navOpen) return;
|
|
870
|
-
const onDocClick = (e) => {
|
|
871
|
-
if (!navRef.current?.contains(e.target)) setNavOpen(false);
|
|
872
|
-
};
|
|
873
|
-
const onKey = (e) => {
|
|
874
|
-
if (e.key === 'Escape') setNavOpen(false);
|
|
875
|
-
};
|
|
876
|
-
document.addEventListener('mousedown', onDocClick);
|
|
877
|
-
document.addEventListener('keydown', onKey);
|
|
878
|
-
return () => {
|
|
879
|
-
document.removeEventListener('mousedown', onDocClick);
|
|
880
|
-
document.removeEventListener('keydown', onKey);
|
|
881
|
-
};
|
|
882
|
-
}, [navOpen]);
|
|
883
|
-
// Mobile drawer's section picker = the resolved tabs (top-level
|
|
884
|
-
// navigation), so it mirrors the desktop tabs row instead of a
|
|
885
|
-
// hardcoded list. Its button shows the active tab's label.
|
|
886
|
-
const navItems = nav?.tabs ?? [];
|
|
887
|
-
const activeNavLabel =
|
|
888
|
-
navItems.find((t) => t.href === nav?.activeTab)?.label ??
|
|
889
|
-
navItems[0]?.label ??
|
|
890
|
-
'';
|
|
891
|
-
const askAI = React.useCallback((q) => {
|
|
892
|
-
setChatQuestion(q);
|
|
893
|
-
setChatOpen(true);
|
|
894
|
-
}, []);
|
|
895
|
-
|
|
896
|
-
// Measure the live sticky chrome — header + (collapsed) TocBar
|
|
897
|
-
// toggle — and publish three CSS variables on `:root`:
|
|
898
|
-
//
|
|
899
|
-
// --velu-header-height live header height
|
|
900
|
-
// --velu-tocbar-height TocBar's TOGGLE row height (always-
|
|
901
|
-
// visible bit; 0 when the bar isn't shown)
|
|
902
|
-
// --velu-scroll-offset header + tocbar-toggle + 1rem
|
|
903
|
-
//
|
|
904
|
-
// Measuring just the toggle row (NOT the expanded list) is
|
|
905
|
-
// important: the expanded dropdown's max-block-size is large, and
|
|
906
|
-
// including it in the offset would inflate scroll-margin /
|
|
907
|
-
// scroll-spy trigger as soon as the user expands the dropdown —
|
|
908
|
-
// causing the page geometry to lurch and the spy / active label
|
|
909
|
-
// to flicker.
|
|
910
|
-
React.useEffect(() => {
|
|
911
|
-
if (typeof window === 'undefined') return;
|
|
912
|
-
const header = document.querySelector('.velu-header');
|
|
913
|
-
if (!header) return;
|
|
914
|
-
const root = document.documentElement;
|
|
915
|
-
const remPx = parseFloat(getComputedStyle(root).fontSize) || 16;
|
|
916
|
-
// Skip same-value writes: these CSS vars affect layout, and an
|
|
917
|
-
// unconditional write on every ResizeObserver fire can feed back into
|
|
918
|
-
// layout (scrollbar appear/disappear flutter at narrow widths — observed
|
|
919
|
-
// as a permanent relayout storm inside the instant-preview iframe).
|
|
920
|
-
let lastHeaderH = -1;
|
|
921
|
-
let lastTocBarH = -1;
|
|
922
|
-
const apply = () => {
|
|
923
|
-
const headerH = Math.round(header.getBoundingClientRect().height);
|
|
924
|
-
// Re-query each apply — the TocBar toggle button is mounted
|
|
925
|
-
// after first paint and its parent may also remount on
|
|
926
|
-
// theme/viewport changes.
|
|
927
|
-
const tocBarToggle = document.querySelector('.velu-toc-bar__toggle');
|
|
928
|
-
const tocBarH = tocBarToggle
|
|
929
|
-
? Math.round(tocBarToggle.getBoundingClientRect().height)
|
|
930
|
-
: 0;
|
|
931
|
-
if (headerH === lastHeaderH && tocBarH === lastTocBarH) return;
|
|
932
|
-
lastHeaderH = headerH;
|
|
933
|
-
lastTocBarH = tocBarH;
|
|
934
|
-
if (headerH > 0)
|
|
935
|
-
root.style.setProperty('--velu-header-height', `${headerH}px`);
|
|
936
|
-
root.style.setProperty('--velu-tocbar-height', `${tocBarH}px`);
|
|
937
|
-
// +1rem of breathing room between the chrome and the heading
|
|
938
|
-
// it's anchoring. The rem is read live so it stays in tokens.
|
|
939
|
-
root.style.setProperty(
|
|
940
|
-
'--velu-scroll-offset',
|
|
941
|
-
`${headerH + tocBarH + remPx}px`,
|
|
942
|
-
);
|
|
943
|
-
};
|
|
944
|
-
apply();
|
|
945
|
-
const ro = new ResizeObserver(apply);
|
|
946
|
-
ro.observe(header);
|
|
947
|
-
// Observe the toggle row only (not the expanded list) so the
|
|
948
|
-
// offset stays put when the user opens the dropdown.
|
|
949
|
-
const tocBarToggle = document.querySelector('.velu-toc-bar__toggle');
|
|
950
|
-
if (tocBarToggle) ro.observe(tocBarToggle);
|
|
951
|
-
return () => ro.disconnect();
|
|
952
|
-
}, []);
|
|
953
|
-
|
|
954
|
-
// The asides (left sidebar + right TOC) scroll independently of the
|
|
955
|
-
// page. On scroll/resize we set data-fade-top/-bottom on each scroll
|
|
956
|
-
// region so CSS can fade its edges (and reveal the nav arrows) only
|
|
957
|
-
// when there's content beyond them. Written imperatively (no
|
|
958
|
-
// re-render). Re-binds on chatOpen toggle (the right TOC
|
|
959
|
-
// mounts/unmounts with it).
|
|
960
|
-
const leftAsideRef = React.useRef(null);
|
|
961
|
-
const rightAsideRef = React.useRef(null);
|
|
962
|
-
React.useEffect(() => {
|
|
963
|
-
const els = [leftAsideRef.current, rightAsideRef.current].filter(Boolean);
|
|
964
|
-
if (!els.length) return;
|
|
965
|
-
const update = () => {
|
|
966
|
-
for (const el of els) {
|
|
967
|
-
el.dataset.fadeTop = el.scrollTop > 0 ? 'true' : 'false';
|
|
968
|
-
el.dataset.fadeBottom =
|
|
969
|
-
el.scrollTop + el.clientHeight < el.scrollHeight - 1
|
|
970
|
-
? 'true'
|
|
971
|
-
: 'false';
|
|
972
|
-
}
|
|
973
|
-
};
|
|
974
|
-
update();
|
|
975
|
-
// Recompute on scroll AND on any size change of the scroll region
|
|
976
|
-
// or its content (ResizeObserver) + window resize — so the fade
|
|
977
|
-
// reflects hidden content persistently, not just during a scroll
|
|
978
|
-
// gesture (e.g. after a route change shifts the nav's height).
|
|
979
|
-
const ro = new ResizeObserver(update);
|
|
980
|
-
els.forEach((el) => {
|
|
981
|
-
el.addEventListener('scroll', update, { passive: true });
|
|
982
|
-
ro.observe(el);
|
|
983
|
-
if (el.firstElementChild) ro.observe(el.firstElementChild);
|
|
984
|
-
});
|
|
985
|
-
window.addEventListener('resize', update);
|
|
986
|
-
return () => {
|
|
987
|
-
els.forEach((el) => el.removeEventListener('scroll', update));
|
|
988
|
-
ro.disconnect();
|
|
989
|
-
window.removeEventListener('resize', update);
|
|
990
|
-
};
|
|
991
|
-
}, [chatOpen, pathname]);
|
|
992
|
-
|
|
993
|
-
// Flag the sidebar section heading currently pinned at the top of the
|
|
994
|
-
// scroll region (CSS sticky gives no "is-stuck" hook). When the pinned
|
|
995
|
-
// heading changes as you scroll, the new one gets data-stuck and
|
|
996
|
-
// animates in (see sidebar.css). Re-binds on page/tab change since the
|
|
997
|
-
// section set changes with it.
|
|
998
|
-
React.useEffect(() => {
|
|
999
|
-
const scroller = leftAsideRef.current;
|
|
1000
|
-
if (!scroller) return;
|
|
1001
|
-
let prev = null;
|
|
1002
|
-
const update = () => {
|
|
1003
|
-
const top = scroller.getBoundingClientRect().top;
|
|
1004
|
-
let stuck = null;
|
|
1005
|
-
for (const h of scroller.querySelectorAll('.velu-sidebar__section')) {
|
|
1006
|
-
if (h.getBoundingClientRect().top <= top + 1) stuck = h;
|
|
1007
|
-
}
|
|
1008
|
-
if (stuck !== prev) {
|
|
1009
|
-
prev?.removeAttribute('data-stuck');
|
|
1010
|
-
stuck?.setAttribute('data-stuck', 'true');
|
|
1011
|
-
prev = stuck;
|
|
1012
|
-
}
|
|
1013
|
-
};
|
|
1014
|
-
update();
|
|
1015
|
-
scroller.addEventListener('scroll', update, { passive: true });
|
|
1016
|
-
return () => scroller.removeEventListener('scroll', update);
|
|
1017
|
-
}, [pathname, chatOpen]);
|
|
1018
|
-
|
|
1019
|
-
// Footer config (from velu.json via virtual:velu-site). Link columns →
|
|
1020
|
-
// full footer section; socials with no columns → inline next to PoweredBy.
|
|
1021
|
-
const footerSocials = site.footer?.socials ?? [];
|
|
1022
|
-
const footerHasLinks = (site.footer?.links?.length ?? 0) > 0;
|
|
1023
|
-
|
|
1024
|
-
// Header top-right actions, built from velu.json's `navbar`: secondary
|
|
1025
|
-
// `links` (plain text links) then the `primary` CTA (a filled button, or an
|
|
1026
|
-
// outlined GitHub button). Empty when unconfigured — no placeholder buttons.
|
|
1027
|
-
const navActions = [];
|
|
1028
|
-
for (const l of site.navbar?.links ?? []) {
|
|
1029
|
-
navActions.push({
|
|
1030
|
-
label: l.label,
|
|
1031
|
-
href: l.href,
|
|
1032
|
-
icon: l.icon || undefined,
|
|
1033
|
-
kind: 'link',
|
|
1034
|
-
external: l.external,
|
|
1035
|
-
});
|
|
1036
|
-
}
|
|
1037
|
-
if (site.navbar?.primary) {
|
|
1038
|
-
const p = site.navbar.primary;
|
|
1039
|
-
navActions.push({
|
|
1040
|
-
label: p.label,
|
|
1041
|
-
href: p.href,
|
|
1042
|
-
kind: p.kind === 'github' ? 'outlined' : 'primary',
|
|
1043
|
-
icon: p.kind === 'github' ? 'github' : undefined,
|
|
1044
|
-
external: p.external,
|
|
1045
|
-
});
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
const footerRef = React.useRef(null);
|
|
1049
|
-
const [footerOverlap, setFooterOverlap] = React.useState(0);
|
|
1050
|
-
React.useEffect(() => {
|
|
1051
|
-
const node = footerRef.current;
|
|
1052
|
-
if (!node) return;
|
|
1053
|
-
const update = () => {
|
|
1054
|
-
const rect = node.getBoundingClientRect();
|
|
1055
|
-
setFooterOverlap(Math.max(0, window.innerHeight - rect.top));
|
|
1056
|
-
};
|
|
1057
|
-
update();
|
|
1058
|
-
window.addEventListener('scroll', update, { passive: true });
|
|
1059
|
-
window.addEventListener('resize', update);
|
|
1060
|
-
return () => {
|
|
1061
|
-
window.removeEventListener('scroll', update);
|
|
1062
|
-
window.removeEventListener('resize', update);
|
|
1063
|
-
};
|
|
1064
|
-
}, []);
|
|
1065
|
-
|
|
1066
|
-
// Sticky AskBar fades out once the user scrolls near the PageFeedback
|
|
1067
|
-
// widget — so it doesn't sit on top of the page-foot widgets.
|
|
1068
|
-
const feedbackRef = React.useRef(null);
|
|
1069
|
-
const [askBarHidden, setAskBarHidden] = React.useState(false);
|
|
1070
|
-
React.useEffect(() => {
|
|
1071
|
-
const node = feedbackRef.current;
|
|
1072
|
-
if (!node) return;
|
|
1073
|
-
const FADE_BUFFER = 64; // matches the rootMargin below
|
|
1074
|
-
const apply = (rect) => {
|
|
1075
|
-
// Hide whenever the feedback's top has reached the (effective)
|
|
1076
|
-
// viewport-bottom line — covers both "feedback is in view" AND
|
|
1077
|
-
// "feedback is already scrolled past" without flickering back on.
|
|
1078
|
-
// Threshold matches the IO's rootMargin so the show/hide flip is
|
|
1079
|
-
// exactly at the same line in both scroll directions.
|
|
1080
|
-
setAskBarHidden(rect.top < window.innerHeight - FADE_BUFFER);
|
|
1081
|
-
};
|
|
1082
|
-
apply(node.getBoundingClientRect());
|
|
1083
|
-
const io = new IntersectionObserver(
|
|
1084
|
-
([entry]) => apply(entry.boundingClientRect),
|
|
1085
|
-
// Trigger ~64px before the feedback enters the viewport, so the
|
|
1086
|
-
// AskBar fades just as the widget starts to peek up from below.
|
|
1087
|
-
{ rootMargin: '0px 0px -64px 0px' },
|
|
1088
|
-
);
|
|
1089
|
-
io.observe(node);
|
|
1090
|
-
return () => io.disconnect();
|
|
1091
|
-
}, []);
|
|
1092
|
-
|
|
1093
|
-
const scrollTo = React.useCallback(
|
|
1094
|
-
(id) => {
|
|
1095
|
-
const node = document.getElementById(id);
|
|
1096
|
-
if (!node) return;
|
|
1097
|
-
// 1) Set + lock the active id IMMEDIATELY so the spy doesn't
|
|
1098
|
-
// overwrite it during the smooth-scroll animation.
|
|
1099
|
-
setActive(id);
|
|
1100
|
-
// 2) Compute the target scroll position MANUALLY (instead of
|
|
1101
|
-
// scrollIntoView). The browser clamps to max-scroll
|
|
1102
|
-
// automatically; the target is baked in at click time and
|
|
1103
|
-
// doesn't drift if layout shifts mid-animation.
|
|
1104
|
-
const offset =
|
|
1105
|
-
parseFloat(
|
|
1106
|
-
getComputedStyle(document.documentElement).getPropertyValue(
|
|
1107
|
-
'--velu-scroll-offset',
|
|
1108
|
-
),
|
|
1109
|
-
) || 0;
|
|
1110
|
-
const top = node.getBoundingClientRect().top + window.scrollY - offset;
|
|
1111
|
-
window.scrollTo({ top, behavior: 'smooth' });
|
|
1112
|
-
},
|
|
1113
|
-
[setActive],
|
|
1114
|
-
);
|
|
1115
|
-
|
|
1116
|
-
const kicker = {
|
|
1117
|
-
fontFamily: 'var(--font-mono)',
|
|
1118
|
-
fontSize: 11,
|
|
1119
|
-
letterSpacing: '0.6px',
|
|
1120
|
-
textTransform: 'uppercase',
|
|
1121
|
-
color: 'var(--muted-color)',
|
|
1122
|
-
};
|
|
1123
|
-
|
|
1124
|
-
return (
|
|
1125
|
-
<div
|
|
1126
|
-
className="velu-docs-layout"
|
|
1127
|
-
data-chat-open={chatOpen ? 'true' : 'false'}
|
|
1128
|
-
data-sidebar-open={sidebarOpen ? 'true' : 'false'}
|
|
1129
|
-
data-drawer-open={drawerOpen ? 'true' : 'false'}
|
|
1130
|
-
data-api={entry?.api ? 'true' : 'false'}
|
|
1131
|
-
data-not-found={isNotFound ? 'true' : undefined}
|
|
1132
|
-
>
|
|
1133
|
-
{/* Scrim — visible at mobile while the drawer OR the chatbot
|
|
1134
|
-
sheet is open. Sits between the article (z-0) and the
|
|
1135
|
-
drawer/chatbot (z-35 / z-50) and blocks pointer events to
|
|
1136
|
-
everything beneath; click closes whichever surface is up. */}
|
|
1137
|
-
<div
|
|
1138
|
-
className="velu-docs-layout__scrim"
|
|
1139
|
-
aria-hidden="true"
|
|
1140
|
-
onClick={() => {
|
|
1141
|
-
setDrawerOpen(false);
|
|
1142
|
-
setChatOpen(false);
|
|
1143
|
-
}}
|
|
1144
|
-
/>
|
|
1145
|
-
{/* Site header — brand, centered search, right-side actions,
|
|
1146
|
-
tabs row. Configurable: pass any number of actions / tabs. */}
|
|
1147
|
-
<PageHeader
|
|
1148
|
-
linkComponent={RouterLink}
|
|
1149
|
-
brand={{
|
|
1150
|
-
label: site.name,
|
|
1151
|
-
href: site.logo?.href || '/',
|
|
1152
|
-
logo: site.logo,
|
|
1153
|
-
}}
|
|
1154
|
-
brandTrailing={
|
|
1155
|
-
versionSwitcher && (
|
|
1156
|
-
<span className="velu-hide-on-mobile">{versionSwitcher}</span>
|
|
1157
|
-
)
|
|
1158
|
-
}
|
|
1159
|
-
tabsTrailing={languageSwitcher || undefined}
|
|
1160
|
-
center={
|
|
1161
|
-
<Cluster space="var(--s-6)" align="center">
|
|
1162
|
-
<Search
|
|
1163
|
-
style={{ inlineSize: '30ch' }}
|
|
1164
|
-
unavailable={IS_DEV_PREVIEW}
|
|
1165
|
-
search={IS_DEV_PREVIEW ? undefined : searchDocs}
|
|
1166
|
-
onSelect={(item) => item.href && navigate(item.href)}
|
|
1167
|
-
/>
|
|
1168
|
-
{/* Ask AI talks to the deployed site's AI backend — hidden in
|
|
1169
|
-
the local dev preview where there's nothing to talk to. */}
|
|
1170
|
-
{!IS_DEV_PREVIEW && (
|
|
1171
|
-
<button
|
|
1172
|
-
type="button"
|
|
1173
|
-
className="velu-header__action velu-header__action--outlined"
|
|
1174
|
-
onClick={() => askAI('')}
|
|
1175
|
-
>
|
|
1176
|
-
<span className="velu-header__action-icon" aria-hidden="true">
|
|
1177
|
-
{resolveIcon('sparkles', { size: '1.5em' })}
|
|
1178
|
-
</span>
|
|
1179
|
-
<span>Ask AI</span>
|
|
1180
|
-
</button>
|
|
1181
|
-
)}
|
|
1182
|
-
</Cluster>
|
|
1183
|
-
}
|
|
1184
|
-
actions={navActions}
|
|
1185
|
-
trailing={<ThemeToggle />}
|
|
1186
|
-
onMenuClick={() => setDrawerOpen((v) => !v)}
|
|
1187
|
-
breadcrumb={nav?.breadcrumb ?? []}
|
|
1188
|
-
activeTab={nav?.activeTab}
|
|
1189
|
-
tabs={nav?.tabs ?? []}
|
|
1190
|
-
/>
|
|
1191
|
-
|
|
1192
|
-
{/* Fixed left sidebar — pinned to viewport-left below the header.
|
|
1193
|
-
Does NOT scroll with the page; the footer rises over its bottom
|
|
1194
|
-
edge thanks to the higher z-index on the footer below.
|
|
1195
|
-
`top` + `bottom` give a robust height (some browsers don't
|
|
1196
|
-
honour `inset-block-start` for fixed positioning the same way
|
|
1197
|
-
as plain `top`). */}
|
|
1198
|
-
<aside
|
|
1199
|
-
className="velu-docs-layout__aside velu-docs-layout__aside--left"
|
|
1200
|
-
style={{
|
|
1201
|
-
/* Bottom edge stays a fixed gap above the viewport bottom,
|
|
1202
|
-
AND lifts to keep that gap above the footer as it scrolls
|
|
1203
|
-
into view (footerOverlap = how far the footer intrudes).
|
|
1204
|
-
Set as a custom prop so the mobile drawer's
|
|
1205
|
-
`inset-block-end: 0` override still wins. */
|
|
1206
|
-
'--velu-aside-bottom': `calc(${footerOverlap}px + var(--s4))`,
|
|
1207
|
-
}}
|
|
1208
|
-
>
|
|
1209
|
-
{/* Drawer body — Stack with 32px gap composes the slots
|
|
1210
|
-
(topbar / tab-dropdown / context / nav). The aside is a flex
|
|
1211
|
-
column (CSS): the pinned slots keep their height and only the
|
|
1212
|
-
nav region scrolls (see velu-docs-nav-scroll below). At wide
|
|
1213
|
-
widths the topbar + docselect are `display: none`. */}
|
|
1214
|
-
<Stack space="var(--s0)" className="velu-docs-aside-stack">
|
|
1215
|
-
<div className="velu-docs-layout__drawer-head">
|
|
1216
|
-
<RouterLink
|
|
1217
|
-
href="/"
|
|
1218
|
-
className="velu-docs-layout__drawer-brand"
|
|
1219
|
-
>
|
|
1220
|
-
{site.logo ? (
|
|
1221
|
-
<Logo logo={site.logo} alt={site.name} />
|
|
1222
|
-
) : (
|
|
1223
|
-
<>
|
|
1224
|
-
<VeluMark />
|
|
1225
|
-
<span className="velu-header__wordmark">{site.name}</span>
|
|
1226
|
-
</>
|
|
1227
|
-
)}
|
|
1228
|
-
</RouterLink>
|
|
1229
|
-
<ThemeToggle />
|
|
1230
|
-
<button
|
|
1231
|
-
type="button"
|
|
1232
|
-
className="velu-docs-layout__drawer-close"
|
|
1233
|
-
aria-label="Close navigation"
|
|
1234
|
-
onClick={() => setDrawerOpen(false)}
|
|
1235
|
-
>
|
|
1236
|
-
<X aria-hidden="true" focusable="false" />
|
|
1237
|
-
</button>
|
|
1238
|
-
</div>
|
|
1239
|
-
{/* Nav dropdown — custom button + menu, fills drawer width.
|
|
1240
|
-
Items mirror the desktop tabs (Home / Docs / Blog).
|
|
1241
|
-
Click-outside + Escape close (see navOpen useEffect
|
|
1242
|
-
above). Chevron rotates 180° on open. */}
|
|
1243
|
-
<div
|
|
1244
|
-
ref={navRef}
|
|
1245
|
-
className="velu-docs-layout__drawer-docselect"
|
|
1246
|
-
data-open={navOpen ? 'true' : 'false'}
|
|
1247
|
-
>
|
|
1248
|
-
<button
|
|
1249
|
-
type="button"
|
|
1250
|
-
className="velu-docs-layout__drawer-docselect-btn"
|
|
1251
|
-
onClick={() => setNavOpen((o) => !o)}
|
|
1252
|
-
aria-haspopup="menu"
|
|
1253
|
-
aria-expanded={navOpen}
|
|
1254
|
-
>
|
|
1255
|
-
<span className="velu-docs-layout__drawer-docselect-label">
|
|
1256
|
-
{activeNavLabel}
|
|
1257
|
-
</span>
|
|
1258
|
-
<ChevronDown
|
|
1259
|
-
className="velu-docs-layout__drawer-docselect-chev"
|
|
1260
|
-
aria-hidden="true"
|
|
1261
|
-
focusable="false"
|
|
1262
|
-
/>
|
|
1263
|
-
</button>
|
|
1264
|
-
<ul
|
|
1265
|
-
className="velu-docs-layout__drawer-docselect-menu"
|
|
1266
|
-
role="menu"
|
|
1267
|
-
aria-hidden={!navOpen}
|
|
1268
|
-
>
|
|
1269
|
-
{navItems.map((it) => (
|
|
1270
|
-
<li key={it.href} role="none">
|
|
1271
|
-
<a
|
|
1272
|
-
role="menuitem"
|
|
1273
|
-
className="velu-docs-layout__drawer-docselect-item"
|
|
1274
|
-
href={it.href}
|
|
1275
|
-
tabIndex={navOpen ? 0 : -1}
|
|
1276
|
-
onClick={() => setNavOpen(false)}
|
|
1277
|
-
>
|
|
1278
|
-
{it.label}
|
|
1279
|
-
</a>
|
|
1280
|
-
</li>
|
|
1281
|
-
))}
|
|
1282
|
-
</ul>
|
|
1283
|
-
</div>
|
|
1284
|
-
{/* Context zone — product switcher + anchors at the top of
|
|
1285
|
-
the sidebar (all breakpoints), plus version/language
|
|
1286
|
-
switchers that only show on mobile (desktop has them in
|
|
1287
|
-
the header). Rendered only when there's something to show
|
|
1288
|
-
so simple projects keep a bare sidebar. */}
|
|
1289
|
-
{(productSwitcher ||
|
|
1290
|
-
versionSwitcher ||
|
|
1291
|
-
languageSwitcher ||
|
|
1292
|
-
anchors.length > 0) && (
|
|
1293
|
-
<Stack space="var(--s-1)" className="velu-docs-context">
|
|
1294
|
-
{productSwitcher}
|
|
1295
|
-
{versionSwitcher && (
|
|
1296
|
-
<span className="velu-show-on-mobile">{versionSwitcher}</span>
|
|
1297
|
-
)}
|
|
1298
|
-
{languageSwitcher && (
|
|
1299
|
-
<span className="velu-show-on-mobile">{languageSwitcher}</span>
|
|
1300
|
-
)}
|
|
1301
|
-
{anchors.length > 0 && (
|
|
1302
|
-
<ul className="velu-docs-anchors">
|
|
1303
|
-
{anchors.map((a, i) => (
|
|
1304
|
-
<li key={i}>
|
|
1305
|
-
<a
|
|
1306
|
-
className="velu-docs-anchors__link"
|
|
1307
|
-
href={a.href}
|
|
1308
|
-
target="_blank"
|
|
1309
|
-
rel="noreferrer"
|
|
1310
|
-
>
|
|
1311
|
-
{a.icon && (
|
|
1312
|
-
<span
|
|
1313
|
-
className="velu-docs-anchors__icon"
|
|
1314
|
-
aria-hidden="true"
|
|
1315
|
-
>
|
|
1316
|
-
{resolveIcon(a.icon, { size: '1em' })}
|
|
1317
|
-
</span>
|
|
1318
|
-
)}
|
|
1319
|
-
<span>{a.label}</span>
|
|
1320
|
-
</a>
|
|
1321
|
-
</li>
|
|
1322
|
-
))}
|
|
1323
|
-
</ul>
|
|
1324
|
-
)}
|
|
1325
|
-
</Stack>
|
|
1326
|
-
)}
|
|
1327
|
-
{/* Hairline separating the top anchor links from the nav sections
|
|
1328
|
-
(faithful to the sidebar design's anchor↔sidebar divider). */}
|
|
1329
|
-
{anchors.length > 0 && (
|
|
1330
|
-
<div className="velu-docs-context-divider" aria-hidden="true" />
|
|
1331
|
-
)}
|
|
1332
|
-
{/* Only this region scrolls — the context zone above stays
|
|
1333
|
-
pinned. The up/down arrows overlay its top/bottom edges and
|
|
1334
|
-
appear (via the data-fade-* attrs the scroll handler sets)
|
|
1335
|
-
when there's content beyond that edge; clicking jumps the
|
|
1336
|
-
nav fully to that end. */}
|
|
1337
|
-
<div className="velu-docs-nav-region">
|
|
1338
|
-
<div
|
|
1339
|
-
ref={leftAsideRef}
|
|
1340
|
-
className="velu-docs-nav-scroll velu-hide-scrollbar"
|
|
1341
|
-
style={{
|
|
1342
|
-
/* Just breathing room — the aside's bottom edge already
|
|
1343
|
-
stays above the footer (see --velu-aside-bottom). */
|
|
1344
|
-
paddingBlockEnd: 'var(--s1)',
|
|
1345
|
-
scrollPaddingBlockEnd: 'var(--s1)',
|
|
1346
|
-
}}
|
|
1347
|
-
>
|
|
1348
|
-
{entry?.api ? (
|
|
1349
|
-
<ApiSidebar
|
|
1350
|
-
sections={(nav?.sidebarSections ?? []).map((s) => ({
|
|
1351
|
-
title: s.title,
|
|
1352
|
-
icon: s.icon,
|
|
1353
|
-
endpoints: (s.items ?? []).map((it) => ({
|
|
1354
|
-
method: it.method,
|
|
1355
|
-
label: it.label,
|
|
1356
|
-
href: it.href,
|
|
1357
|
-
})),
|
|
1358
|
-
}))}
|
|
1359
|
-
activeHref={pathname}
|
|
1360
|
-
linkComponent={RouterLink}
|
|
1361
|
-
/>
|
|
1362
|
-
) : (
|
|
1363
|
-
<Sidebar
|
|
1364
|
-
sections={nav?.sidebarSections ?? []}
|
|
1365
|
-
activeHref={pathname}
|
|
1366
|
-
linkComponent={RouterLink}
|
|
1367
|
-
/>
|
|
1368
|
-
)}
|
|
1369
|
-
</div>
|
|
1370
|
-
<button
|
|
1371
|
-
type="button"
|
|
1372
|
-
className="velu-docs-nav-arrow velu-docs-nav-arrow--up"
|
|
1373
|
-
aria-label="Scroll navigation to top"
|
|
1374
|
-
onClick={() =>
|
|
1375
|
-
leftAsideRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
|
1376
|
-
}
|
|
1377
|
-
>
|
|
1378
|
-
<ChevronUp aria-hidden="true" focusable="false" />
|
|
1379
|
-
</button>
|
|
1380
|
-
<button
|
|
1381
|
-
type="button"
|
|
1382
|
-
className="velu-docs-nav-arrow velu-docs-nav-arrow--down"
|
|
1383
|
-
aria-label="Scroll navigation to bottom"
|
|
1384
|
-
onClick={() =>
|
|
1385
|
-
leftAsideRef.current?.scrollTo({
|
|
1386
|
-
top: leftAsideRef.current.scrollHeight,
|
|
1387
|
-
behavior: 'smooth',
|
|
1388
|
-
})
|
|
1389
|
-
}
|
|
1390
|
-
>
|
|
1391
|
-
<ChevronDown aria-hidden="true" focusable="false" />
|
|
1392
|
-
</button>
|
|
1393
|
-
</div>
|
|
1394
|
-
</Stack>
|
|
1395
|
-
</aside>
|
|
1396
|
-
|
|
1397
|
-
{/* No separate rail element — at narrow widths the sidebar's
|
|
1398
|
-
aside ANIMATES its `inline-size` between 240px (open) and
|
|
1399
|
-
--velu-rail-width (closed), and its inner Sidebar nav fades
|
|
1400
|
-
out via opacity. The aside's border-inline-end therefore
|
|
1401
|
-
becomes the rail's right border when collapsed. This makes
|
|
1402
|
-
the collapse a smooth width+opacity transition instead of a
|
|
1403
|
-
display:none snap. See docs-layout.css. */}
|
|
1404
|
-
|
|
1405
|
-
{/* Fixed right TOC — pinned to viewport-right. Hidden while the
|
|
1406
|
-
Ask-AI chatbot is open (the panel takes that edge). */}
|
|
1407
|
-
{!chatOpen && (
|
|
1408
|
-
<aside
|
|
1409
|
-
ref={rightAsideRef}
|
|
1410
|
-
className="velu-docs-layout__aside velu-docs-layout__aside--right velu-hide-scrollbar"
|
|
1411
|
-
style={{
|
|
1412
|
-
/* Dynamic footer-overlap padding only — static geometry
|
|
1413
|
-
lives in docs-layout.css. */
|
|
1414
|
-
paddingBlockEnd: `calc(var(--s3) + ${footerOverlap}px + 2rem)`,
|
|
1415
|
-
scrollPaddingBlockEnd: `calc(${footerOverlap}px + 2rem)`,
|
|
1416
|
-
}}
|
|
1417
|
-
>
|
|
1418
|
-
{/* API reference pages put their code samples in the right rail
|
|
1419
|
-
(where the TOC sits for normal pages). */}
|
|
1420
|
-
{entry?.api && entry.operation ? (
|
|
1421
|
-
<ApiSamples
|
|
1422
|
-
samples={entry.samples}
|
|
1423
|
-
responses={entry.operation.responses}
|
|
1424
|
-
/>
|
|
1425
|
-
) : (
|
|
1426
|
-
<Toc items={toc} activeId={activeId} onSelect={scrollTo} />
|
|
1427
|
-
)}
|
|
1428
|
-
</aside>
|
|
1429
|
-
)}
|
|
1430
|
-
|
|
1431
|
-
{/* Center column. Margin to reserve aside space lives in
|
|
1432
|
-
docs-layout.css (drops to 0 at narrow widths via @container,
|
|
1433
|
-
and the right margin collapses to 0 while the chatbot is
|
|
1434
|
-
open via [data-chat-open="true"]). */}
|
|
1435
|
-
<div className="velu-docs-layout__center">
|
|
1436
|
-
{isNotFound ? (
|
|
1437
|
-
<main className="velu-404-main">
|
|
1438
|
-
<NotFound
|
|
1439
|
-
homeHref="/"
|
|
1440
|
-
linkComponent={RouterLink}
|
|
1441
|
-
onSearch={() =>
|
|
1442
|
-
window.dispatchEvent(
|
|
1443
|
-
new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, metaKey: true }),
|
|
1444
|
-
)
|
|
1445
|
-
}
|
|
1446
|
-
onAskAI={IS_DEV_PREVIEW ? undefined : () => askAI('')}
|
|
1447
|
-
/>
|
|
1448
|
-
</main>
|
|
1449
|
-
) : (
|
|
1450
|
-
<>
|
|
1451
|
-
{/* Narrow-layout TOC bar — always in DOM, only visible at
|
|
1452
|
-
< 1024px (toggled by @container in toc-bar.css). Shares
|
|
1453
|
-
its `items` + `activeId` + `onSelect` API with the
|
|
1454
|
-
right-rail <Toc> so both views are driven by the same
|
|
1455
|
-
data; whichever one is visible at a given width responds
|
|
1456
|
-
to the same scroll-spy state. */}
|
|
1457
|
-
<TocBar items={toc} activeId={activeId} onSelect={scrollTo} />
|
|
1458
|
-
<main
|
|
1459
|
-
className="velu-docs-layout__main"
|
|
1460
|
-
style={{
|
|
1461
|
-
/* No padding-block-end — the article's last child (PoweredBy)
|
|
1462
|
-
owns the gap to the footer via its own margin-bottom, so
|
|
1463
|
-
padding-bottom here would double-count it. `position:
|
|
1464
|
-
relative` makes <main> the offsetParent for the absolutely
|
|
1465
|
-
positioned sidebar-toggle below — so its `top: 0` lands
|
|
1466
|
-
at the top of the article area, BELOW the sticky TocBar
|
|
1467
|
-
(which lives outside <main>). Padding-inline lives in CSS
|
|
1468
|
-
so the @container query can collapse the start side when
|
|
1469
|
-
the sidebar is closed (no "ghost column" on the left). */
|
|
1470
|
-
position: 'relative',
|
|
1471
|
-
color: 'var(--text-color)',
|
|
1472
|
-
}}
|
|
1473
|
-
>
|
|
1474
|
-
{/* Sidebar toggle — narrow-layout only (hidden by @container
|
|
1475
|
-
at wide widths). Lives inside <main> so its absolute
|
|
1476
|
-
position is relative to the article area, not the centre
|
|
1477
|
-
column, and therefore sits BELOW the TocBar rather than
|
|
1478
|
-
overlapping it. Chevron flips direction with the
|
|
1479
|
-
`data-sidebar-open` data attribute on the layout root. */}
|
|
1480
|
-
<button
|
|
1481
|
-
type="button"
|
|
1482
|
-
className="velu-docs-layout__sidebar-toggle"
|
|
1483
|
-
onClick={() => setSidebarOpen((v) => !v)}
|
|
1484
|
-
aria-label={sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
|
1485
|
-
aria-expanded={sidebarOpen}
|
|
1486
|
-
>
|
|
1487
|
-
<span aria-hidden="true">
|
|
1488
|
-
{resolveIcon(sidebarOpen ? 'chevron-left' : 'chevron-right', {
|
|
1489
|
-
size: '1em',
|
|
1490
|
-
})}
|
|
1491
|
-
</span>
|
|
1492
|
-
</button>
|
|
1493
|
-
<div className="velu-docs-layout__article" data-pagefind-body="">
|
|
1494
|
-
{/* Per-page agent/IDE action bar: the section eyebrow + a
|
|
1495
|
-
"Copy Page" split-button whose dropdown is driven by the
|
|
1496
|
-
Mintlify-compatible `contextual` config. Renders nothing
|
|
1497
|
-
when there's neither an eyebrow nor any enabled options. */}
|
|
1498
|
-
<ContextMenu
|
|
1499
|
-
eyebrow={
|
|
1500
|
-
nav?.breadcrumb && nav.breadcrumb.length > 1
|
|
1501
|
-
? nav.breadcrumb[nav.breadcrumb.length - 2].label
|
|
1502
|
-
: undefined
|
|
1503
|
-
}
|
|
1504
|
-
pageUrl={pathname}
|
|
1505
|
-
title={frontmatter.title}
|
|
1506
|
-
isApi={entry?.api === true}
|
|
1507
|
-
siteName={site.name}
|
|
1508
|
-
options={site.contextual?.options ?? []}
|
|
1509
|
-
onAssistant={IS_DEV_PREVIEW ? undefined : () => askAI('')}
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
{frontmatter.
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
footer
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
<
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
//
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
if (
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Routes, Route, useLocation, useNavigate, Link } from 'react-router-dom';
|
|
3
|
+
import { MDXProvider } from '@mdx-js/react';
|
|
4
|
+
// The project's pages + navigation, generated from velu.json by
|
|
5
|
+
// vite-plugin-velu-site (see src/vite-plugin-velu-site.js). `pages` is
|
|
6
|
+
// url → { Component, frontmatter, toc } (or { missing:true }).
|
|
7
|
+
import { pages, navigation, site } from 'virtual:velu-site';
|
|
8
|
+
import { resolve, normalizeUrl } from '../navigation.js';
|
|
9
|
+
import {
|
|
10
|
+
Stack,
|
|
11
|
+
Cluster,
|
|
12
|
+
ThemeToggle,
|
|
13
|
+
Sidebar,
|
|
14
|
+
NavSelect,
|
|
15
|
+
Toc,
|
|
16
|
+
TocBar,
|
|
17
|
+
ContextMenu,
|
|
18
|
+
Callout,
|
|
19
|
+
Accordion,
|
|
20
|
+
AccordionGroup,
|
|
21
|
+
Card,
|
|
22
|
+
CardGroup,
|
|
23
|
+
Switcher,
|
|
24
|
+
Image,
|
|
25
|
+
CodeBlock,
|
|
26
|
+
CodeGroup,
|
|
27
|
+
Columns,
|
|
28
|
+
Field,
|
|
29
|
+
Prompt,
|
|
30
|
+
Steps,
|
|
31
|
+
Step,
|
|
32
|
+
AskBar,
|
|
33
|
+
Chatbot,
|
|
34
|
+
PageFeedback,
|
|
35
|
+
PageNav,
|
|
36
|
+
PageFooter,
|
|
37
|
+
PageHeader,
|
|
38
|
+
PoweredBy,
|
|
39
|
+
defaultMdxComponents,
|
|
40
|
+
resolveIcon,
|
|
41
|
+
Search,
|
|
42
|
+
pagefindSearch,
|
|
43
|
+
createDocsAssistant,
|
|
44
|
+
Logo,
|
|
45
|
+
SocialLinks,
|
|
46
|
+
Tree,
|
|
47
|
+
Folder,
|
|
48
|
+
File,
|
|
49
|
+
TryItBar,
|
|
50
|
+
ApiClient,
|
|
51
|
+
ApiField,
|
|
52
|
+
ApiSidebar,
|
|
53
|
+
ApiSamples,
|
|
54
|
+
NotFound,
|
|
55
|
+
VeluMark,
|
|
56
|
+
} from 'velu-ui';
|
|
57
|
+
import { X, ChevronDown, ChevronUp } from 'lucide-react';
|
|
58
|
+
import ErrorBoundary from './ErrorBoundary.jsx';
|
|
59
|
+
|
|
60
|
+
// True under `velu dev`, false in the deployed production build (Vite injects
|
|
61
|
+
// this). AI features (Ask AI, the Ask-a-question bar) and full-text search run
|
|
62
|
+
// on the deployed site, so they're hidden / shown-as-unavailable in the local
|
|
63
|
+
// preview. Identical on SSR + client in each mode, so no hydration mismatch.
|
|
64
|
+
const IS_DEV_PREVIEW = import.meta.env.DEV;
|
|
65
|
+
|
|
66
|
+
const CALLOUT_TYPES = [
|
|
67
|
+
'note',
|
|
68
|
+
'warning',
|
|
69
|
+
'info',
|
|
70
|
+
'tip',
|
|
71
|
+
'check',
|
|
72
|
+
'danger',
|
|
73
|
+
'callout',
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
function CalloutGallery() {
|
|
77
|
+
return (
|
|
78
|
+
<Stack space="var(--s0)">
|
|
79
|
+
{CALLOUT_TYPES.map((t) => (
|
|
80
|
+
<Callout key={t} type={t}>
|
|
81
|
+
This is a {t}. This is a {t}. This is a {t}. This is a {t}.
|
|
82
|
+
</Callout>
|
|
83
|
+
))}
|
|
84
|
+
<p style={{ color: 'var(--muted-color)' }}>
|
|
85
|
+
Custom callouts can be built with a custom icon & color:
|
|
86
|
+
</p>
|
|
87
|
+
<Callout
|
|
88
|
+
icon="sparkles"
|
|
89
|
+
stroke="var(--accent-color)"
|
|
90
|
+
bg="color-mix(in srgb, var(--accent-color) 8%, transparent)"
|
|
91
|
+
>
|
|
92
|
+
A custom callout — lucide id <code>"sparkles"</code> with custom{' '}
|
|
93
|
+
<code>stroke</code> & <code>bg</code>.
|
|
94
|
+
</Callout>
|
|
95
|
+
|
|
96
|
+
<Callout type="info">
|
|
97
|
+
This is a multi-line callout. It keeps going and going so the text
|
|
98
|
+
wraps onto several lines — the icon sits on the first line, the text
|
|
99
|
+
wraps beside it, and every line after flows back under the icon (no
|
|
100
|
+
hanging indent). Resize the window to watch the wrapping reflow while
|
|
101
|
+
the icon stays put on line one. This is a multi-line callout. It keeps
|
|
102
|
+
going so the text wraps onto several lines.
|
|
103
|
+
</Callout>
|
|
104
|
+
</Stack>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function AccordionDemo() {
|
|
109
|
+
return (
|
|
110
|
+
<Stack space="var(--s1)">
|
|
111
|
+
<Accordion title="This is an accordion">
|
|
112
|
+
This is content inside the accordion. Hover the header to see the
|
|
113
|
+
surface tint; the chevron rotates when expanded.
|
|
114
|
+
</Accordion>
|
|
115
|
+
<Accordion title="This is an accordion (open by default)" defaultOpen>
|
|
116
|
+
This one renders expanded — header takes the surface tint and a
|
|
117
|
+
divider separates it from this content.
|
|
118
|
+
</Accordion>
|
|
119
|
+
<AccordionGroup>
|
|
120
|
+
<Accordion title="This is an accordion">
|
|
121
|
+
Grouped items share one bordered box with dividers between them.
|
|
122
|
+
</Accordion>
|
|
123
|
+
<Accordion title="This is an accordion" defaultOpen>
|
|
124
|
+
This is content inside the accordion.
|
|
125
|
+
</Accordion>
|
|
126
|
+
<Accordion title="This is an accordion">
|
|
127
|
+
No per-item border or radius — the group owns the chrome.
|
|
128
|
+
</Accordion>
|
|
129
|
+
</AccordionGroup>
|
|
130
|
+
</Stack>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function CardDemo() {
|
|
135
|
+
return (
|
|
136
|
+
<Stack space="var(--s2)">
|
|
137
|
+
<Card icon="align-justify" title="Card Title">
|
|
138
|
+
This is how you use a card with an icon and a link. (Cards are not
|
|
139
|
+
clickable — only the CTA card's link is.)
|
|
140
|
+
</Card>
|
|
141
|
+
|
|
142
|
+
<Card icon="align-justify" title="Card Title" horizontal>
|
|
143
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
|
|
144
|
+
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
|
|
145
|
+
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
|
|
146
|
+
commodo consequat.
|
|
147
|
+
</Card>
|
|
148
|
+
|
|
149
|
+
<Card
|
|
150
|
+
img="https://picsum.photos/seed/velu/1200/560"
|
|
151
|
+
imgAlt="Yosemite valley"
|
|
152
|
+
title="Card Title"
|
|
153
|
+
>
|
|
154
|
+
This is how you use a card with an icon. The image fills the container
|
|
155
|
+
and is clipped to the card radius.
|
|
156
|
+
</Card>
|
|
157
|
+
|
|
158
|
+
<Card
|
|
159
|
+
icon="align-justify"
|
|
160
|
+
title="Card Title"
|
|
161
|
+
cta={{ label: 'Click Here', href: '#cards' }}
|
|
162
|
+
>
|
|
163
|
+
This is how you use a card with an icon and a link. The "Click Here"
|
|
164
|
+
link below is the only clickable element.
|
|
165
|
+
</Card>
|
|
166
|
+
|
|
167
|
+
<CardGroup>
|
|
168
|
+
<Card icon="align-justify" title="Card Title">
|
|
169
|
+
This is how you use a card with an icon and a link.
|
|
170
|
+
</Card>
|
|
171
|
+
<Card icon="align-justify" title="Card Title">
|
|
172
|
+
This is how you use a card with an icon and a link.
|
|
173
|
+
</Card>
|
|
174
|
+
</CardGroup>
|
|
175
|
+
</Stack>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function SwitcherBox({ children }) {
|
|
180
|
+
return (
|
|
181
|
+
<div
|
|
182
|
+
style={{
|
|
183
|
+
border: 'var(--border-width) solid var(--border-color)',
|
|
184
|
+
borderRadius: 'var(--radius-sm)',
|
|
185
|
+
padding: 'var(--s0) var(--s3)',
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
{children}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function SwitcherDemo() {
|
|
194
|
+
return (
|
|
195
|
+
<Stack space="var(--s1)">
|
|
196
|
+
<p style={{ color: 'var(--muted-color)' }}>
|
|
197
|
+
Resize the window — these flip between a row and a column at the
|
|
198
|
+
container threshold, with no media queries.
|
|
199
|
+
</p>
|
|
200
|
+
<Switcher space="var(--s0)" threshold="30rem">
|
|
201
|
+
<SwitcherBox>One</SwitcherBox>
|
|
202
|
+
<SwitcherBox>Two</SwitcherBox>
|
|
203
|
+
<SwitcherBox>Three</SwitcherBox>
|
|
204
|
+
</Switcher>
|
|
205
|
+
<p style={{ color: 'var(--muted-color)' }}>
|
|
206
|
+
With <code>limit={2}</code>: more than 2 items forces vertical
|
|
207
|
+
regardless of width.
|
|
208
|
+
</p>
|
|
209
|
+
<Switcher space="var(--s0)" threshold="30rem" limit={2}>
|
|
210
|
+
<SwitcherBox>One</SwitcherBox>
|
|
211
|
+
<SwitcherBox>Two</SwitcherBox>
|
|
212
|
+
<SwitcherBox>Three</SwitcherBox>
|
|
213
|
+
</Switcher>
|
|
214
|
+
</Stack>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const DEMO_JS = `let greeting = function (name) {
|
|
219
|
+
console.log(\`Hello, \${name}!\`);
|
|
220
|
+
};`;
|
|
221
|
+
const DEMO_RB = `def greeting(name)
|
|
222
|
+
puts "Hello, #{name}!"
|
|
223
|
+
end`;
|
|
224
|
+
const DEMO_PY = `def greeting(name):
|
|
225
|
+
print(f"Hello, {name}!")`;
|
|
226
|
+
|
|
227
|
+
function CodeBlockDemo() {
|
|
228
|
+
return (
|
|
229
|
+
<Stack space="var(--s2)">
|
|
230
|
+
<p style={{ color: 'var(--muted-color)' }}>Code Group</p>
|
|
231
|
+
<CodeGroup>
|
|
232
|
+
<CodeBlock title="Javascript" language="javascript">
|
|
233
|
+
{DEMO_JS}
|
|
234
|
+
</CodeBlock>
|
|
235
|
+
<CodeBlock title="Ruby" language="ruby">
|
|
236
|
+
{DEMO_RB}
|
|
237
|
+
</CodeBlock>
|
|
238
|
+
<CodeBlock title="Python" language="python">
|
|
239
|
+
{DEMO_PY}
|
|
240
|
+
</CodeBlock>
|
|
241
|
+
</CodeGroup>
|
|
242
|
+
|
|
243
|
+
<p style={{ color: 'var(--muted-color)' }}>Code (with file name)</p>
|
|
244
|
+
<CodeBlock filename="index.js" language="javascript">
|
|
245
|
+
{DEMO_JS}
|
|
246
|
+
</CodeBlock>
|
|
247
|
+
|
|
248
|
+
<p style={{ color: 'var(--muted-color)' }}>
|
|
249
|
+
With line numbers (icon suppressed via <code>withIcon={'{false}'}</code>)
|
|
250
|
+
</p>
|
|
251
|
+
<CodeBlock
|
|
252
|
+
filename="index.js"
|
|
253
|
+
language="javascript"
|
|
254
|
+
withIcon={false}
|
|
255
|
+
lineNumbers
|
|
256
|
+
>
|
|
257
|
+
{DEMO_JS}
|
|
258
|
+
</CodeBlock>
|
|
259
|
+
|
|
260
|
+
<p style={{ color: 'var(--muted-color)' }}>
|
|
261
|
+
Line highlight — single line (<code>highlight="2"</code>)
|
|
262
|
+
</p>
|
|
263
|
+
<CodeBlock
|
|
264
|
+
filename="index.js"
|
|
265
|
+
language="javascript"
|
|
266
|
+
lineNumbers
|
|
267
|
+
highlight="2"
|
|
268
|
+
>
|
|
269
|
+
{DEMO_JS}
|
|
270
|
+
</CodeBlock>
|
|
271
|
+
|
|
272
|
+
<p style={{ color: 'var(--muted-color)' }}>
|
|
273
|
+
Line highlight — range + single (<code>highlight="1-2,4"</code>)
|
|
274
|
+
</p>
|
|
275
|
+
<CodeBlock
|
|
276
|
+
filename="server.js"
|
|
277
|
+
language="javascript"
|
|
278
|
+
lineNumbers
|
|
279
|
+
highlight="1-2,4"
|
|
280
|
+
>
|
|
281
|
+
{`const express = require('express');
|
|
282
|
+
const app = express();
|
|
283
|
+
|
|
284
|
+
app.get('/', (req, res) => res.send('Hello'));
|
|
285
|
+
|
|
286
|
+
app.listen(3000);`}
|
|
287
|
+
</CodeBlock>
|
|
288
|
+
</Stack>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function ApiDemo() {
|
|
293
|
+
const path = '/project/preview/{projectId}';
|
|
294
|
+
const curl = `curl --request POST \\
|
|
295
|
+
--url https://api.mintlify.com/v1/project/update/{projectId} \\
|
|
296
|
+
--header 'Authorization: Bearer <token>'`;
|
|
297
|
+
return (
|
|
298
|
+
<Stack space="var(--s2)">
|
|
299
|
+
<p style={{ color: 'var(--muted-color)' }}>Try-it bar</p>
|
|
300
|
+
<Stack space="var(--s-1)">
|
|
301
|
+
<TryItBar method="POST" path={path} />
|
|
302
|
+
<TryItBar method="GET" path={path} />
|
|
303
|
+
<TryItBar method="PUT" path={path} />
|
|
304
|
+
<TryItBar method="DELETE" path={path} />
|
|
305
|
+
<TryItBar method="PATCH" path={path} />
|
|
306
|
+
</Stack>
|
|
307
|
+
|
|
308
|
+
<p style={{ color: 'var(--muted-color)' }}>API sidebar</p>
|
|
309
|
+
<ApiSidebar
|
|
310
|
+
activeHref="/api/status"
|
|
311
|
+
sections={[
|
|
312
|
+
{
|
|
313
|
+
title: 'Admin',
|
|
314
|
+
icon: 'rocket',
|
|
315
|
+
endpoints: [
|
|
316
|
+
{ method: 'POST', label: 'Trigger', href: '/api/trigger' },
|
|
317
|
+
{
|
|
318
|
+
method: 'GET',
|
|
319
|
+
label: 'Get deployment-status',
|
|
320
|
+
href: '/api/status',
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
method: 'DELETE',
|
|
324
|
+
label: 'Trigger Preview deployment',
|
|
325
|
+
href: '/api/preview',
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
},
|
|
329
|
+
]}
|
|
330
|
+
/>
|
|
331
|
+
|
|
332
|
+
<p style={{ color: 'var(--muted-color)' }}>API Client</p>
|
|
333
|
+
{/* Only the ApiClient breaks out of the 46rem prose column — the
|
|
334
|
+
TryItBars above stay at the normal column width. clamp keeps
|
|
335
|
+
it ≥ 100% (so it never collapses when the main-area calc goes
|
|
336
|
+
negative on narrow screens) and ≤ 64rem. */}
|
|
337
|
+
<div
|
|
338
|
+
style={{
|
|
339
|
+
width:
|
|
340
|
+
'clamp(100%, calc(100vw - 240px - 280px - var(--s4) * 2), 64rem)',
|
|
341
|
+
}}
|
|
342
|
+
>
|
|
343
|
+
<ApiClient
|
|
344
|
+
method="DELETE"
|
|
345
|
+
label="Trigger Delete"
|
|
346
|
+
path={path}
|
|
347
|
+
onClose={() => {}}
|
|
348
|
+
description="Trigger a documentation deployment programmatically to publish updates outside of Git workflows."
|
|
349
|
+
aside={
|
|
350
|
+
<Stack space="var(--s1)">
|
|
351
|
+
<Callout type="danger">401 — Unauthorized</Callout>
|
|
352
|
+
<CodeGroup>
|
|
353
|
+
<CodeBlock title="Response" language="json">
|
|
354
|
+
{`{\n "status": 401,\n "error": "Unauthorized"\n}`}
|
|
355
|
+
</CodeBlock>
|
|
356
|
+
<CodeBlock title="Headers" language="http">
|
|
357
|
+
{`HTTP/1.1 401 Unauthorized
|
|
358
|
+
date: Mon, 27 Apr 2026 12:00:55 GMT
|
|
359
|
+
content-type: application/json; charset=utf-8
|
|
360
|
+
content-length: 24
|
|
361
|
+
connection: close
|
|
362
|
+
x-powered-by: Express
|
|
363
|
+
access-control-allow-origin: *`}
|
|
364
|
+
</CodeBlock>
|
|
365
|
+
</CodeGroup>
|
|
366
|
+
<CodeGroup>
|
|
367
|
+
<CodeBlock title="Curl" language="bash">
|
|
368
|
+
{curl}
|
|
369
|
+
</CodeBlock>
|
|
370
|
+
<CodeBlock title="Ruby" language="ruby">
|
|
371
|
+
{`require 'net/http'
|
|
372
|
+
uri = URI('https://api.mintlify.com/v1/project/update/<projectId>')
|
|
373
|
+
req = Net::HTTP::Post.new(uri)
|
|
374
|
+
req['Authorization'] = 'Bearer <token>'
|
|
375
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }`}
|
|
376
|
+
</CodeBlock>
|
|
377
|
+
<CodeBlock title="Python" language="python">
|
|
378
|
+
{`import requests
|
|
379
|
+
requests.post(
|
|
380
|
+
'https://api.mintlify.com/v1/project/update/<projectId>',
|
|
381
|
+
headers={'Authorization': 'Bearer <token>'},
|
|
382
|
+
)`}
|
|
383
|
+
</CodeBlock>
|
|
384
|
+
</CodeGroup>
|
|
385
|
+
<CodeGroup>
|
|
386
|
+
<CodeBlock title="202" language="json">
|
|
387
|
+
{`{\n "statusId": "<string>"\n}`}
|
|
388
|
+
</CodeBlock>
|
|
389
|
+
<CodeBlock title="200" language="json">
|
|
390
|
+
{`{\n "statusId": "<string>",\n "completedAt": "2026-04-27T12:00:55Z"\n}`}
|
|
391
|
+
</CodeBlock>
|
|
392
|
+
</CodeGroup>
|
|
393
|
+
</Stack>
|
|
394
|
+
}
|
|
395
|
+
>
|
|
396
|
+
<AccordionGroup>
|
|
397
|
+
<Accordion title="Authorization" defaultOpen>
|
|
398
|
+
<Stack space="var(--s1)">
|
|
399
|
+
<ApiField
|
|
400
|
+
name="Authorization"
|
|
401
|
+
type="string"
|
|
402
|
+
required
|
|
403
|
+
prefix="Bearer"
|
|
404
|
+
>
|
|
405
|
+
The Authorization header expects a Bearer token. Use an
|
|
406
|
+
admin API key (prefixed with <code>mint_</code>).
|
|
407
|
+
</ApiField>
|
|
408
|
+
</Stack>
|
|
409
|
+
</Accordion>
|
|
410
|
+
<Accordion title="Path" defaultOpen>
|
|
411
|
+
<Stack space="var(--s1)">
|
|
412
|
+
<ApiField name="projectId" type="string" required>
|
|
413
|
+
Your project ID. Copy it from the API keys page.
|
|
414
|
+
</ApiField>
|
|
415
|
+
<ApiField name="bodyId" type="string" required>
|
|
416
|
+
Your body ID. Copy it from the API keys page.
|
|
417
|
+
</ApiField>
|
|
418
|
+
</Stack>
|
|
419
|
+
</Accordion>
|
|
420
|
+
<Accordion title="Query">
|
|
421
|
+
<Stack space="var(--s1)">
|
|
422
|
+
<ApiField name="branch" type="string">
|
|
423
|
+
Git branch to deploy. Defaults to the production branch.
|
|
424
|
+
</ApiField>
|
|
425
|
+
<ApiField name="dryRun" type="boolean" default="false">
|
|
426
|
+
When true, validates the deployment without publishing.
|
|
427
|
+
</ApiField>
|
|
428
|
+
</Stack>
|
|
429
|
+
</Accordion>
|
|
430
|
+
<Accordion title="Headers">
|
|
431
|
+
<Stack space="var(--s1)">
|
|
432
|
+
<ApiField name="X-Idempotency-Key" type="string">
|
|
433
|
+
Optional unique key so retried requests don't trigger
|
|
434
|
+
duplicate deployments.
|
|
435
|
+
</ApiField>
|
|
436
|
+
</Stack>
|
|
437
|
+
</Accordion>
|
|
438
|
+
<Accordion title="Body">
|
|
439
|
+
<Stack space="var(--s1)">
|
|
440
|
+
<ApiField name="message" type="string">
|
|
441
|
+
Deployment message shown in the activity log.
|
|
442
|
+
</ApiField>
|
|
443
|
+
<ApiField name="notify" type="boolean" default="true">
|
|
444
|
+
Whether to notify when the deployment completes.
|
|
445
|
+
</ApiField>
|
|
446
|
+
</Stack>
|
|
447
|
+
</Accordion>
|
|
448
|
+
</AccordionGroup>
|
|
449
|
+
</ApiClient>
|
|
450
|
+
</div>
|
|
451
|
+
</Stack>
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function TreeDemo() {
|
|
456
|
+
return (
|
|
457
|
+
<Tree>
|
|
458
|
+
<Folder name="app">
|
|
459
|
+
<File name="layout.tsx" />
|
|
460
|
+
<File name="page.tsx" />
|
|
461
|
+
<Folder name="api" defaultOpen={false}>
|
|
462
|
+
<File name="route.ts" />
|
|
463
|
+
</Folder>
|
|
464
|
+
</Folder>
|
|
465
|
+
<File name="package.json" />
|
|
466
|
+
</Tree>
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function StepsDemo() {
|
|
471
|
+
return (
|
|
472
|
+
<Steps>
|
|
473
|
+
<Step title="Install the CLI">Run the install script.</Step>
|
|
474
|
+
<Step title="Bootstrap a new project">
|
|
475
|
+
Scaffold a fresh velu project with a single command, then drop in the
|
|
476
|
+
starter MDX so you can begin editing right away. The CLI prompts for a
|
|
477
|
+
project name and target directory, copies the template files, and
|
|
478
|
+
installs dependencies in the background while you keep working.
|
|
479
|
+
</Step>
|
|
480
|
+
<Step title="Add your first page">
|
|
481
|
+
Drop an MDX file into the <code>content/</code> directory.
|
|
482
|
+
</Step>
|
|
483
|
+
<Step title="Configure navigation">
|
|
484
|
+
Edit <code>velu.config.js</code> and add your page paths under the
|
|
485
|
+
sidebar tree. Nested groups are supported, every item can carry an
|
|
486
|
+
icon, and external links are flagged automatically. Save the file and
|
|
487
|
+
the dev server hot-reloads with the new structure.
|
|
488
|
+
</Step>
|
|
489
|
+
<Step title="Preview locally">
|
|
490
|
+
Run <code>velu dev</code> — the local preview opens at
|
|
491
|
+
<code> localhost:5173</code> with SSR + HMR. Edits to any MDX or React
|
|
492
|
+
component reflect instantly. The right-rail TOC is generated from the
|
|
493
|
+
page's headings; the comet trail follows scroll.
|
|
494
|
+
</Step>
|
|
495
|
+
<Step title="Push and ship">Open a PR. Done.</Step>
|
|
496
|
+
</Steps>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function PromptDemo() {
|
|
501
|
+
return (
|
|
502
|
+
<Prompt title="Generate clear, concise documentation." openInCursor>
|
|
503
|
+
{`You are a **technical writing assistant**. Write documentation that is clear, accurate, and concise.
|
|
504
|
+
- Use second-person voice
|
|
505
|
+
- Avoid jargon
|
|
506
|
+
- Lead with what the reader can do, not how it works internally
|
|
507
|
+
- Code blocks use the project's language conventions
|
|
508
|
+
- Every example must be runnable as-is`}
|
|
509
|
+
</Prompt>
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function FieldDemo() {
|
|
514
|
+
const desc =
|
|
515
|
+
'An example of a parameter field. An example of a parameter field. An example of a parameter field. An example of a parameter field. An example of a parameter field. An example of a parameter field';
|
|
516
|
+
return (
|
|
517
|
+
<div>
|
|
518
|
+
<Field
|
|
519
|
+
name="param"
|
|
520
|
+
pre="pre"
|
|
521
|
+
type="string"
|
|
522
|
+
required
|
|
523
|
+
default={3}
|
|
524
|
+
post="post"
|
|
525
|
+
>
|
|
526
|
+
{desc}
|
|
527
|
+
</Field>
|
|
528
|
+
<Field
|
|
529
|
+
name="param"
|
|
530
|
+
pre="pre"
|
|
531
|
+
type="string"
|
|
532
|
+
required
|
|
533
|
+
default={3}
|
|
534
|
+
post="post"
|
|
535
|
+
>
|
|
536
|
+
{desc}
|
|
537
|
+
</Field>
|
|
538
|
+
<Field
|
|
539
|
+
name="param"
|
|
540
|
+
pre="pre"
|
|
541
|
+
type="string"
|
|
542
|
+
required
|
|
543
|
+
default={3}
|
|
544
|
+
post="post"
|
|
545
|
+
>
|
|
546
|
+
{desc}
|
|
547
|
+
</Field>
|
|
548
|
+
</div>
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function ColumnsDemo() {
|
|
553
|
+
return (
|
|
554
|
+
<Stack space="var(--s2)">
|
|
555
|
+
<p style={{ color: 'var(--muted-color)' }}>
|
|
556
|
+
Two equal columns (<code>cols={2}</code>) — collapses to a
|
|
557
|
+
single stack when items would fall below their per-item min width.
|
|
558
|
+
</p>
|
|
559
|
+
<Columns cols={2}>
|
|
560
|
+
<Card
|
|
561
|
+
icon="align-justify"
|
|
562
|
+
title="Card Title"
|
|
563
|
+
cta={{ label: 'Click Here', href: '#columns' }}
|
|
564
|
+
>
|
|
565
|
+
This is how you use a card with an icon and a link. Clicking on
|
|
566
|
+
this card brings you to the Columns page.
|
|
567
|
+
</Card>
|
|
568
|
+
<Card
|
|
569
|
+
icon="align-justify"
|
|
570
|
+
title="Card Title"
|
|
571
|
+
cta={{ label: 'Click Here', href: '#columns' }}
|
|
572
|
+
>
|
|
573
|
+
This is how you use a card with an icon and a link. Clicking on
|
|
574
|
+
this card brings you to the Columns page.
|
|
575
|
+
</Card>
|
|
576
|
+
</Columns>
|
|
577
|
+
|
|
578
|
+
<p style={{ color: 'var(--muted-color)' }}>
|
|
579
|
+
Heterogeneous children — a Card alongside a CodeBlock.
|
|
580
|
+
</p>
|
|
581
|
+
<Columns cols={2}>
|
|
582
|
+
<Card icon="sparkles" title="With a card">
|
|
583
|
+
Cards, callouts, code, images — anything composes in a column.
|
|
584
|
+
</Card>
|
|
585
|
+
<CodeBlock filename="hello.js" language="javascript">
|
|
586
|
+
{DEMO_JS}
|
|
587
|
+
</CodeBlock>
|
|
588
|
+
</Columns>
|
|
589
|
+
|
|
590
|
+
<p style={{ color: 'var(--muted-color)' }}>
|
|
591
|
+
Three columns (<code>cols={3}</code>) with a stacked column
|
|
592
|
+
in the middle.
|
|
593
|
+
</p>
|
|
594
|
+
<Columns cols={3}>
|
|
595
|
+
<Card icon="rocket" title="Left">
|
|
596
|
+
Single card on the left.
|
|
597
|
+
</Card>
|
|
598
|
+
<Stack space="var(--s0)">
|
|
599
|
+
<Card icon="book" title="Top">
|
|
600
|
+
Two cards stacked via Stack inside one column.
|
|
601
|
+
</Card>
|
|
602
|
+
<Card icon="brain" title="Bottom">
|
|
603
|
+
Stack lets a column carry heterogeneous, multi-item content.
|
|
604
|
+
</Card>
|
|
605
|
+
</Stack>
|
|
606
|
+
<Card icon="zap" title="Right">
|
|
607
|
+
Single card on the right.
|
|
608
|
+
</Card>
|
|
609
|
+
</Columns>
|
|
610
|
+
</Stack>
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const DEMO_IMG = 'https://picsum.photos/seed/velu-yosemite/1200/600';
|
|
615
|
+
const DEMO_SHOT = 'https://picsum.photos/seed/velu-shot/600/1000';
|
|
616
|
+
|
|
617
|
+
function ImageDemo() {
|
|
618
|
+
return (
|
|
619
|
+
<Stack space="var(--s2)">
|
|
620
|
+
<p style={{ color: 'var(--muted-color)' }}>Image</p>
|
|
621
|
+
<Image src={DEMO_IMG} alt="" />
|
|
622
|
+
|
|
623
|
+
<p style={{ color: 'var(--muted-color)' }}>
|
|
624
|
+
Image with caption (caption can be markdown later)
|
|
625
|
+
</p>
|
|
626
|
+
<Image src={DEMO_IMG} alt="" caption="Caption for this image" />
|
|
627
|
+
|
|
628
|
+
<p style={{ color: 'var(--muted-color)' }}>Image with window chrome</p>
|
|
629
|
+
<Image src={DEMO_IMG} alt="" chrome="window" />
|
|
630
|
+
|
|
631
|
+
<p style={{ color: 'var(--muted-color)' }}>Image with display frame</p>
|
|
632
|
+
<Image src={DEMO_SHOT} alt="" chrome="frame" caption="Caption can also be added" />
|
|
633
|
+
</Stack>
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const RouterLink = ({ href, ...rest }) => <Link to={href} {...rest} />;
|
|
638
|
+
|
|
639
|
+
// Flatten a nested TOC tree to {id, label, depth} for scroll-spy.
|
|
640
|
+
function flat(nodes, depth = 0, out = []) {
|
|
641
|
+
for (const n of nodes) {
|
|
642
|
+
out.push({ id: n.id, label: n.label, depth });
|
|
643
|
+
if (n.children) flat(n.children, depth + 1, out);
|
|
644
|
+
}
|
|
645
|
+
return out;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Scroll-spy: active = the section whose top last crossed the trigger line.
|
|
649
|
+
/**
|
|
650
|
+
* useScrollSpy — tracks the section currently in view, with an
|
|
651
|
+
* explicit "lock" override for click-driven scrolls.
|
|
652
|
+
*
|
|
653
|
+
* Without the lock, clicking a TOC item triggers a smooth-scroll
|
|
654
|
+
* animation; during the ~500ms animation the scroll listener fires
|
|
655
|
+
* many times with the page at intermediate positions, and the
|
|
656
|
+
* "closest section" answer flickers across multiple sections
|
|
657
|
+
* before settling — visible as the TocBar label briefly showing
|
|
658
|
+
* the wrong heading. The lock blocks spy updates for a configurable
|
|
659
|
+
* window so the click's intended `activeId` survives the animation.
|
|
660
|
+
*
|
|
661
|
+
* Returns `[activeId, setActive]` where `setActive(id)` writes the
|
|
662
|
+
* active id directly AND locks the spy for ~800ms.
|
|
663
|
+
*/
|
|
664
|
+
function useScrollSpy(ids) {
|
|
665
|
+
const [activeId, setActiveId] = React.useState(ids[0]);
|
|
666
|
+
const lockUntilRef = React.useRef(0);
|
|
667
|
+
React.useEffect(() => {
|
|
668
|
+
const onScroll = () => {
|
|
669
|
+
if (Date.now() < lockUntilRef.current) return;
|
|
670
|
+
// Trigger line reads from --velu-scroll-offset (published by
|
|
671
|
+
// App.jsx as header + tocbar-toggle + 1rem). Same offset CSS
|
|
672
|
+
// uses for scroll-margin, so a heading freshly scrolled into
|
|
673
|
+
// view sits exactly at the trigger. Semantics: pick the
|
|
674
|
+
// section whose top is CLOSEST to the trigger; tie-break
|
|
675
|
+
// favours later sections in document order.
|
|
676
|
+
const trigger =
|
|
677
|
+
parseFloat(
|
|
678
|
+
getComputedStyle(document.documentElement).getPropertyValue(
|
|
679
|
+
'--velu-scroll-offset',
|
|
680
|
+
),
|
|
681
|
+
) || 0;
|
|
682
|
+
const vh = window.innerHeight;
|
|
683
|
+
let best = ids[0];
|
|
684
|
+
let bestDist = Infinity;
|
|
685
|
+
for (const id of ids) {
|
|
686
|
+
const node = document.getElementById(id);
|
|
687
|
+
if (!node) continue;
|
|
688
|
+
const top = node.getBoundingClientRect().top;
|
|
689
|
+
if (top >= vh) continue;
|
|
690
|
+
const dist = Math.abs(top - trigger);
|
|
691
|
+
if (dist <= bestDist) {
|
|
692
|
+
bestDist = dist;
|
|
693
|
+
best = id;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
setActiveId(best);
|
|
697
|
+
};
|
|
698
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
699
|
+
onScroll();
|
|
700
|
+
return () => window.removeEventListener('scroll', onScroll);
|
|
701
|
+
}, [ids]);
|
|
702
|
+
|
|
703
|
+
const setActive = React.useCallback((id) => {
|
|
704
|
+
setActiveId(id);
|
|
705
|
+
// ~800ms covers the default smooth-scroll animation duration.
|
|
706
|
+
lockUntilRef.current = Date.now() + 800;
|
|
707
|
+
}, []);
|
|
708
|
+
|
|
709
|
+
return [activeId, setActive];
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Slugger used for the frontmatter-derived h1 id — same library as
|
|
713
|
+
// extract-toc + rehype-slug, so the id we render in the DOM matches
|
|
714
|
+
// what the TOC's `pageId` will compute. (One instance per page render
|
|
715
|
+
// is fine; the slugger is stateful only across calls on the SAME
|
|
716
|
+
// instance.)
|
|
717
|
+
import GithubSlugger from 'github-slugger';
|
|
718
|
+
|
|
719
|
+
function DocsPage() {
|
|
720
|
+
// Resolve the current route → page entry + navigation context. Both
|
|
721
|
+
// SSR and client compute these from the same pathname + the same
|
|
722
|
+
// shared `resolve()`, so the render is identical (hydration-safe).
|
|
723
|
+
const location = useLocation();
|
|
724
|
+
const navigate = useNavigate();
|
|
725
|
+
const pathname = normalizeUrl(location.pathname);
|
|
726
|
+
const entry = pages[pathname];
|
|
727
|
+
// `navigation`/`pages` are module constants in dev/build (the deps never
|
|
728
|
+
// change there); the instant preview's virtual-site substitute reassigns
|
|
729
|
+
// the `pages` live binding as pages finish compiling, so listing them re-
|
|
730
|
+
// resolves nav (sidebar labels, breadcrumb, prev/next) as titles arrive.
|
|
731
|
+
const nav = React.useMemo(
|
|
732
|
+
() => resolve(pathname, navigation, pages),
|
|
733
|
+
[pathname, navigation, pages],
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
// Search source: Pagefind for content/excerpts, but resolve each result's
|
|
737
|
+
// breadcrumb from the real NAVIGATION (a page's nav group can differ from its
|
|
738
|
+
// URL path — e.g. essentials/markdown.mdx lives under the "Writing Content"
|
|
739
|
+
// group). Page rows show the path to the page; anchor rows include the page.
|
|
740
|
+
const searchDocs = React.useCallback(async (q) => {
|
|
741
|
+
const results = await pagefindSearch(q);
|
|
742
|
+
if (!Array.isArray(results)) return results;
|
|
743
|
+
return results.map((r) => {
|
|
744
|
+
const res = resolve(r.href.split('#')[0], navigation, pages);
|
|
745
|
+
if (!res?.breadcrumb?.length) return r;
|
|
746
|
+
const labels = res.breadcrumb.map((c) => c.label);
|
|
747
|
+
const crumbs = r.kind === 'anchor' ? labels : labels.slice(0, -1);
|
|
748
|
+
return crumbs.length ? { ...r, breadcrumb: crumbs } : r;
|
|
749
|
+
});
|
|
750
|
+
}, []);
|
|
751
|
+
|
|
752
|
+
// Ask-AI backend: wire the chatbot to the public docs-AI-assistant API only
|
|
753
|
+
// in a real build AND when an apiBase is configured (velu.json → assistant).
|
|
754
|
+
// Otherwise `ask` stays undefined and the Chatbot uses its canned demo answer
|
|
755
|
+
// (dev preview, or a site that hasn't enabled the assistant).
|
|
756
|
+
const assistant = React.useMemo(() => {
|
|
757
|
+
if (IS_DEV_PREVIEW) return null;
|
|
758
|
+
const apiBase = site.assistant?.apiBase;
|
|
759
|
+
if (!apiBase) return null;
|
|
760
|
+
return createDocsAssistant({ apiBase, host: site.assistant?.host });
|
|
761
|
+
}, []);
|
|
762
|
+
|
|
763
|
+
const frontmatter = entry?.frontmatter ?? {};
|
|
764
|
+
const PageComponent = entry?.Component ?? null;
|
|
765
|
+
const pageToc = entry?.toc ?? [];
|
|
766
|
+
// No renderable page for this route → show the 404 page (a clean centered
|
|
767
|
+
// takeover: header + footer stay, the docs sidebar/TOC are hidden).
|
|
768
|
+
const isNotFound = !PageComponent;
|
|
769
|
+
|
|
770
|
+
// Switcher option sets (only render a switcher when an axis has >1
|
|
771
|
+
// option). Anchors are pinned sidebar links shown in the context zone.
|
|
772
|
+
const productOptions = nav?.products ?? [];
|
|
773
|
+
const versionOptions = nav?.versions ?? [];
|
|
774
|
+
const languageOptions = nav?.languages ?? [];
|
|
775
|
+
const anchors = nav?.anchors ?? [];
|
|
776
|
+
const versionSwitcher = versionOptions.length > 1 && (
|
|
777
|
+
<NavSelect
|
|
778
|
+
size="sm"
|
|
779
|
+
value={nav.activeVersion}
|
|
780
|
+
options={versionOptions}
|
|
781
|
+
linkComponent={RouterLink}
|
|
782
|
+
ariaLabel="Version"
|
|
783
|
+
/>
|
|
784
|
+
);
|
|
785
|
+
const languageSwitcher = languageOptions.length > 1 && (
|
|
786
|
+
<NavSelect
|
|
787
|
+
bare
|
|
788
|
+
icon="globe"
|
|
789
|
+
value={nav.activeLanguage}
|
|
790
|
+
valueCode={nav.activeLanguageCode}
|
|
791
|
+
options={languageOptions}
|
|
792
|
+
linkComponent={RouterLink}
|
|
793
|
+
ariaLabel="Language"
|
|
794
|
+
/>
|
|
795
|
+
);
|
|
796
|
+
const productSwitcher = productOptions.length > 1 && (
|
|
797
|
+
<NavSelect
|
|
798
|
+
value={nav.activeProduct}
|
|
799
|
+
options={productOptions}
|
|
800
|
+
linkComponent={RouterLink}
|
|
801
|
+
ariaLabel="Product"
|
|
802
|
+
/>
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
// Keep document.title / meta description / <html lang> in sync on client-side
|
|
806
|
+
// navigation. The HTML is SSR'd with a per-page (build) or generic (dev) title;
|
|
807
|
+
// without this the tab title would stay frozen on the first page as you
|
|
808
|
+
// navigate the SPA, and JS-executing crawlers would read the stale value.
|
|
809
|
+
React.useEffect(() => {
|
|
810
|
+
const t = frontmatter.title;
|
|
811
|
+
document.title = isNotFound
|
|
812
|
+
? `Page not found - ${site.name}`
|
|
813
|
+
: t && t !== site.name
|
|
814
|
+
? `${t} - ${site.name}`
|
|
815
|
+
: site.name;
|
|
816
|
+
let descTag = document.querySelector('meta[name="description"]');
|
|
817
|
+
if (!descTag) {
|
|
818
|
+
descTag = document.createElement('meta');
|
|
819
|
+
descTag.setAttribute('name', 'description');
|
|
820
|
+
document.head.appendChild(descTag);
|
|
821
|
+
}
|
|
822
|
+
descTag.setAttribute('content', frontmatter.description || '');
|
|
823
|
+
document.documentElement.lang = nav?.activeLanguageCode || 'en';
|
|
824
|
+
}, [pathname, isNotFound, frontmatter.title, frontmatter.description, nav?.activeLanguageCode]);
|
|
825
|
+
|
|
826
|
+
// Frontmatter title needs an id so scroll-spy + click-to-scroll work
|
|
827
|
+
// against it like any other heading.
|
|
828
|
+
const pageId = React.useMemo(() => {
|
|
829
|
+
if (!frontmatter.title) return null;
|
|
830
|
+
return new GithubSlugger().slug(frontmatter.title);
|
|
831
|
+
}, [frontmatter.title]);
|
|
832
|
+
|
|
833
|
+
// Combined TOC: the frontmatter title is the page's top-level entry,
|
|
834
|
+
// and the MDX-derived headings (h2s) become its children. Without this,
|
|
835
|
+
// the right rail starts from the first h2 — orphaning the page title
|
|
836
|
+
// that sits visibly above the article.
|
|
837
|
+
const toc = React.useMemo(() => {
|
|
838
|
+
if (!frontmatter.title || !pageId) return pageToc;
|
|
839
|
+
return [{ id: pageId, label: frontmatter.title, children: pageToc }];
|
|
840
|
+
}, [pageId, frontmatter.title, pageToc]);
|
|
841
|
+
|
|
842
|
+
// Flat list of section ids for scroll-spy — sourced from the same
|
|
843
|
+
// nested TOC tree we pass to the right-rail <Toc>, so spy + render
|
|
844
|
+
// can't drift out of sync with the MDX content.
|
|
845
|
+
const ids = React.useMemo(() => flat(toc).map((s) => s.id), [toc]);
|
|
846
|
+
const [activeId, setActive] = useScrollSpy(ids);
|
|
847
|
+
|
|
848
|
+
// Ask-AI chatbot: opens (slides in from the side) when a question is
|
|
849
|
+
// submitted in the AskBar. While it's open the TOC is hidden.
|
|
850
|
+
const [chatOpen, setChatOpen] = React.useState(false);
|
|
851
|
+
const [chatQuestion, setChatQuestion] = React.useState('');
|
|
852
|
+
|
|
853
|
+
// Left sidebar open/closed (narrow widths) — chevron toggle. Defaults
|
|
854
|
+
// open per design; safe to default true since the narrow-width
|
|
855
|
+
// sidebar is in-flow and doesn't obscure content.
|
|
856
|
+
const [sidebarOpen, setSidebarOpen] = React.useState(true);
|
|
857
|
+
// Mobile drawer open/closed (< 640px) — burger / breadcrumb / X /
|
|
858
|
+
// scrim drive this. Defaults closed so refreshing the page at
|
|
859
|
+
// mobile doesn't surface the drawer over the article. Independent
|
|
860
|
+
// of sidebarOpen so neither breakpoint's default leaks into the
|
|
861
|
+
// other.
|
|
862
|
+
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
|
863
|
+
|
|
864
|
+
// Drawer's nav dropdown — list of section links (Home/Docs/Blog),
|
|
865
|
+
// mirrors the desktop tabs. Click-outside + Escape close.
|
|
866
|
+
const [navOpen, setNavOpen] = React.useState(false);
|
|
867
|
+
const navRef = React.useRef(null);
|
|
868
|
+
React.useEffect(() => {
|
|
869
|
+
if (!navOpen) return;
|
|
870
|
+
const onDocClick = (e) => {
|
|
871
|
+
if (!navRef.current?.contains(e.target)) setNavOpen(false);
|
|
872
|
+
};
|
|
873
|
+
const onKey = (e) => {
|
|
874
|
+
if (e.key === 'Escape') setNavOpen(false);
|
|
875
|
+
};
|
|
876
|
+
document.addEventListener('mousedown', onDocClick);
|
|
877
|
+
document.addEventListener('keydown', onKey);
|
|
878
|
+
return () => {
|
|
879
|
+
document.removeEventListener('mousedown', onDocClick);
|
|
880
|
+
document.removeEventListener('keydown', onKey);
|
|
881
|
+
};
|
|
882
|
+
}, [navOpen]);
|
|
883
|
+
// Mobile drawer's section picker = the resolved tabs (top-level
|
|
884
|
+
// navigation), so it mirrors the desktop tabs row instead of a
|
|
885
|
+
// hardcoded list. Its button shows the active tab's label.
|
|
886
|
+
const navItems = nav?.tabs ?? [];
|
|
887
|
+
const activeNavLabel =
|
|
888
|
+
navItems.find((t) => t.href === nav?.activeTab)?.label ??
|
|
889
|
+
navItems[0]?.label ??
|
|
890
|
+
'';
|
|
891
|
+
const askAI = React.useCallback((q) => {
|
|
892
|
+
setChatQuestion(q);
|
|
893
|
+
setChatOpen(true);
|
|
894
|
+
}, []);
|
|
895
|
+
|
|
896
|
+
// Measure the live sticky chrome — header + (collapsed) TocBar
|
|
897
|
+
// toggle — and publish three CSS variables on `:root`:
|
|
898
|
+
//
|
|
899
|
+
// --velu-header-height live header height
|
|
900
|
+
// --velu-tocbar-height TocBar's TOGGLE row height (always-
|
|
901
|
+
// visible bit; 0 when the bar isn't shown)
|
|
902
|
+
// --velu-scroll-offset header + tocbar-toggle + 1rem
|
|
903
|
+
//
|
|
904
|
+
// Measuring just the toggle row (NOT the expanded list) is
|
|
905
|
+
// important: the expanded dropdown's max-block-size is large, and
|
|
906
|
+
// including it in the offset would inflate scroll-margin /
|
|
907
|
+
// scroll-spy trigger as soon as the user expands the dropdown —
|
|
908
|
+
// causing the page geometry to lurch and the spy / active label
|
|
909
|
+
// to flicker.
|
|
910
|
+
React.useEffect(() => {
|
|
911
|
+
if (typeof window === 'undefined') return;
|
|
912
|
+
const header = document.querySelector('.velu-header');
|
|
913
|
+
if (!header) return;
|
|
914
|
+
const root = document.documentElement;
|
|
915
|
+
const remPx = parseFloat(getComputedStyle(root).fontSize) || 16;
|
|
916
|
+
// Skip same-value writes: these CSS vars affect layout, and an
|
|
917
|
+
// unconditional write on every ResizeObserver fire can feed back into
|
|
918
|
+
// layout (scrollbar appear/disappear flutter at narrow widths — observed
|
|
919
|
+
// as a permanent relayout storm inside the instant-preview iframe).
|
|
920
|
+
let lastHeaderH = -1;
|
|
921
|
+
let lastTocBarH = -1;
|
|
922
|
+
const apply = () => {
|
|
923
|
+
const headerH = Math.round(header.getBoundingClientRect().height);
|
|
924
|
+
// Re-query each apply — the TocBar toggle button is mounted
|
|
925
|
+
// after first paint and its parent may also remount on
|
|
926
|
+
// theme/viewport changes.
|
|
927
|
+
const tocBarToggle = document.querySelector('.velu-toc-bar__toggle');
|
|
928
|
+
const tocBarH = tocBarToggle
|
|
929
|
+
? Math.round(tocBarToggle.getBoundingClientRect().height)
|
|
930
|
+
: 0;
|
|
931
|
+
if (headerH === lastHeaderH && tocBarH === lastTocBarH) return;
|
|
932
|
+
lastHeaderH = headerH;
|
|
933
|
+
lastTocBarH = tocBarH;
|
|
934
|
+
if (headerH > 0)
|
|
935
|
+
root.style.setProperty('--velu-header-height', `${headerH}px`);
|
|
936
|
+
root.style.setProperty('--velu-tocbar-height', `${tocBarH}px`);
|
|
937
|
+
// +1rem of breathing room between the chrome and the heading
|
|
938
|
+
// it's anchoring. The rem is read live so it stays in tokens.
|
|
939
|
+
root.style.setProperty(
|
|
940
|
+
'--velu-scroll-offset',
|
|
941
|
+
`${headerH + tocBarH + remPx}px`,
|
|
942
|
+
);
|
|
943
|
+
};
|
|
944
|
+
apply();
|
|
945
|
+
const ro = new ResizeObserver(apply);
|
|
946
|
+
ro.observe(header);
|
|
947
|
+
// Observe the toggle row only (not the expanded list) so the
|
|
948
|
+
// offset stays put when the user opens the dropdown.
|
|
949
|
+
const tocBarToggle = document.querySelector('.velu-toc-bar__toggle');
|
|
950
|
+
if (tocBarToggle) ro.observe(tocBarToggle);
|
|
951
|
+
return () => ro.disconnect();
|
|
952
|
+
}, []);
|
|
953
|
+
|
|
954
|
+
// The asides (left sidebar + right TOC) scroll independently of the
|
|
955
|
+
// page. On scroll/resize we set data-fade-top/-bottom on each scroll
|
|
956
|
+
// region so CSS can fade its edges (and reveal the nav arrows) only
|
|
957
|
+
// when there's content beyond them. Written imperatively (no
|
|
958
|
+
// re-render). Re-binds on chatOpen toggle (the right TOC
|
|
959
|
+
// mounts/unmounts with it).
|
|
960
|
+
const leftAsideRef = React.useRef(null);
|
|
961
|
+
const rightAsideRef = React.useRef(null);
|
|
962
|
+
React.useEffect(() => {
|
|
963
|
+
const els = [leftAsideRef.current, rightAsideRef.current].filter(Boolean);
|
|
964
|
+
if (!els.length) return;
|
|
965
|
+
const update = () => {
|
|
966
|
+
for (const el of els) {
|
|
967
|
+
el.dataset.fadeTop = el.scrollTop > 0 ? 'true' : 'false';
|
|
968
|
+
el.dataset.fadeBottom =
|
|
969
|
+
el.scrollTop + el.clientHeight < el.scrollHeight - 1
|
|
970
|
+
? 'true'
|
|
971
|
+
: 'false';
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
update();
|
|
975
|
+
// Recompute on scroll AND on any size change of the scroll region
|
|
976
|
+
// or its content (ResizeObserver) + window resize — so the fade
|
|
977
|
+
// reflects hidden content persistently, not just during a scroll
|
|
978
|
+
// gesture (e.g. after a route change shifts the nav's height).
|
|
979
|
+
const ro = new ResizeObserver(update);
|
|
980
|
+
els.forEach((el) => {
|
|
981
|
+
el.addEventListener('scroll', update, { passive: true });
|
|
982
|
+
ro.observe(el);
|
|
983
|
+
if (el.firstElementChild) ro.observe(el.firstElementChild);
|
|
984
|
+
});
|
|
985
|
+
window.addEventListener('resize', update);
|
|
986
|
+
return () => {
|
|
987
|
+
els.forEach((el) => el.removeEventListener('scroll', update));
|
|
988
|
+
ro.disconnect();
|
|
989
|
+
window.removeEventListener('resize', update);
|
|
990
|
+
};
|
|
991
|
+
}, [chatOpen, pathname]);
|
|
992
|
+
|
|
993
|
+
// Flag the sidebar section heading currently pinned at the top of the
|
|
994
|
+
// scroll region (CSS sticky gives no "is-stuck" hook). When the pinned
|
|
995
|
+
// heading changes as you scroll, the new one gets data-stuck and
|
|
996
|
+
// animates in (see sidebar.css). Re-binds on page/tab change since the
|
|
997
|
+
// section set changes with it.
|
|
998
|
+
React.useEffect(() => {
|
|
999
|
+
const scroller = leftAsideRef.current;
|
|
1000
|
+
if (!scroller) return;
|
|
1001
|
+
let prev = null;
|
|
1002
|
+
const update = () => {
|
|
1003
|
+
const top = scroller.getBoundingClientRect().top;
|
|
1004
|
+
let stuck = null;
|
|
1005
|
+
for (const h of scroller.querySelectorAll('.velu-sidebar__section')) {
|
|
1006
|
+
if (h.getBoundingClientRect().top <= top + 1) stuck = h;
|
|
1007
|
+
}
|
|
1008
|
+
if (stuck !== prev) {
|
|
1009
|
+
prev?.removeAttribute('data-stuck');
|
|
1010
|
+
stuck?.setAttribute('data-stuck', 'true');
|
|
1011
|
+
prev = stuck;
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
update();
|
|
1015
|
+
scroller.addEventListener('scroll', update, { passive: true });
|
|
1016
|
+
return () => scroller.removeEventListener('scroll', update);
|
|
1017
|
+
}, [pathname, chatOpen]);
|
|
1018
|
+
|
|
1019
|
+
// Footer config (from velu.json via virtual:velu-site). Link columns →
|
|
1020
|
+
// full footer section; socials with no columns → inline next to PoweredBy.
|
|
1021
|
+
const footerSocials = site.footer?.socials ?? [];
|
|
1022
|
+
const footerHasLinks = (site.footer?.links?.length ?? 0) > 0;
|
|
1023
|
+
|
|
1024
|
+
// Header top-right actions, built from velu.json's `navbar`: secondary
|
|
1025
|
+
// `links` (plain text links) then the `primary` CTA (a filled button, or an
|
|
1026
|
+
// outlined GitHub button). Empty when unconfigured — no placeholder buttons.
|
|
1027
|
+
const navActions = [];
|
|
1028
|
+
for (const l of site.navbar?.links ?? []) {
|
|
1029
|
+
navActions.push({
|
|
1030
|
+
label: l.label,
|
|
1031
|
+
href: l.href,
|
|
1032
|
+
icon: l.icon || undefined,
|
|
1033
|
+
kind: 'link',
|
|
1034
|
+
external: l.external,
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
if (site.navbar?.primary) {
|
|
1038
|
+
const p = site.navbar.primary;
|
|
1039
|
+
navActions.push({
|
|
1040
|
+
label: p.label,
|
|
1041
|
+
href: p.href,
|
|
1042
|
+
kind: p.kind === 'github' ? 'outlined' : 'primary',
|
|
1043
|
+
icon: p.kind === 'github' ? 'github' : undefined,
|
|
1044
|
+
external: p.external,
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const footerRef = React.useRef(null);
|
|
1049
|
+
const [footerOverlap, setFooterOverlap] = React.useState(0);
|
|
1050
|
+
React.useEffect(() => {
|
|
1051
|
+
const node = footerRef.current;
|
|
1052
|
+
if (!node) return;
|
|
1053
|
+
const update = () => {
|
|
1054
|
+
const rect = node.getBoundingClientRect();
|
|
1055
|
+
setFooterOverlap(Math.max(0, window.innerHeight - rect.top));
|
|
1056
|
+
};
|
|
1057
|
+
update();
|
|
1058
|
+
window.addEventListener('scroll', update, { passive: true });
|
|
1059
|
+
window.addEventListener('resize', update);
|
|
1060
|
+
return () => {
|
|
1061
|
+
window.removeEventListener('scroll', update);
|
|
1062
|
+
window.removeEventListener('resize', update);
|
|
1063
|
+
};
|
|
1064
|
+
}, []);
|
|
1065
|
+
|
|
1066
|
+
// Sticky AskBar fades out once the user scrolls near the PageFeedback
|
|
1067
|
+
// widget — so it doesn't sit on top of the page-foot widgets.
|
|
1068
|
+
const feedbackRef = React.useRef(null);
|
|
1069
|
+
const [askBarHidden, setAskBarHidden] = React.useState(false);
|
|
1070
|
+
React.useEffect(() => {
|
|
1071
|
+
const node = feedbackRef.current;
|
|
1072
|
+
if (!node) return;
|
|
1073
|
+
const FADE_BUFFER = 64; // matches the rootMargin below
|
|
1074
|
+
const apply = (rect) => {
|
|
1075
|
+
// Hide whenever the feedback's top has reached the (effective)
|
|
1076
|
+
// viewport-bottom line — covers both "feedback is in view" AND
|
|
1077
|
+
// "feedback is already scrolled past" without flickering back on.
|
|
1078
|
+
// Threshold matches the IO's rootMargin so the show/hide flip is
|
|
1079
|
+
// exactly at the same line in both scroll directions.
|
|
1080
|
+
setAskBarHidden(rect.top < window.innerHeight - FADE_BUFFER);
|
|
1081
|
+
};
|
|
1082
|
+
apply(node.getBoundingClientRect());
|
|
1083
|
+
const io = new IntersectionObserver(
|
|
1084
|
+
([entry]) => apply(entry.boundingClientRect),
|
|
1085
|
+
// Trigger ~64px before the feedback enters the viewport, so the
|
|
1086
|
+
// AskBar fades just as the widget starts to peek up from below.
|
|
1087
|
+
{ rootMargin: '0px 0px -64px 0px' },
|
|
1088
|
+
);
|
|
1089
|
+
io.observe(node);
|
|
1090
|
+
return () => io.disconnect();
|
|
1091
|
+
}, []);
|
|
1092
|
+
|
|
1093
|
+
const scrollTo = React.useCallback(
|
|
1094
|
+
(id) => {
|
|
1095
|
+
const node = document.getElementById(id);
|
|
1096
|
+
if (!node) return;
|
|
1097
|
+
// 1) Set + lock the active id IMMEDIATELY so the spy doesn't
|
|
1098
|
+
// overwrite it during the smooth-scroll animation.
|
|
1099
|
+
setActive(id);
|
|
1100
|
+
// 2) Compute the target scroll position MANUALLY (instead of
|
|
1101
|
+
// scrollIntoView). The browser clamps to max-scroll
|
|
1102
|
+
// automatically; the target is baked in at click time and
|
|
1103
|
+
// doesn't drift if layout shifts mid-animation.
|
|
1104
|
+
const offset =
|
|
1105
|
+
parseFloat(
|
|
1106
|
+
getComputedStyle(document.documentElement).getPropertyValue(
|
|
1107
|
+
'--velu-scroll-offset',
|
|
1108
|
+
),
|
|
1109
|
+
) || 0;
|
|
1110
|
+
const top = node.getBoundingClientRect().top + window.scrollY - offset;
|
|
1111
|
+
window.scrollTo({ top, behavior: 'smooth' });
|
|
1112
|
+
},
|
|
1113
|
+
[setActive],
|
|
1114
|
+
);
|
|
1115
|
+
|
|
1116
|
+
const kicker = {
|
|
1117
|
+
fontFamily: 'var(--font-mono)',
|
|
1118
|
+
fontSize: 11,
|
|
1119
|
+
letterSpacing: '0.6px',
|
|
1120
|
+
textTransform: 'uppercase',
|
|
1121
|
+
color: 'var(--muted-color)',
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
return (
|
|
1125
|
+
<div
|
|
1126
|
+
className="velu-docs-layout"
|
|
1127
|
+
data-chat-open={chatOpen ? 'true' : 'false'}
|
|
1128
|
+
data-sidebar-open={sidebarOpen ? 'true' : 'false'}
|
|
1129
|
+
data-drawer-open={drawerOpen ? 'true' : 'false'}
|
|
1130
|
+
data-api={entry?.api ? 'true' : 'false'}
|
|
1131
|
+
data-not-found={isNotFound ? 'true' : undefined}
|
|
1132
|
+
>
|
|
1133
|
+
{/* Scrim — visible at mobile while the drawer OR the chatbot
|
|
1134
|
+
sheet is open. Sits between the article (z-0) and the
|
|
1135
|
+
drawer/chatbot (z-35 / z-50) and blocks pointer events to
|
|
1136
|
+
everything beneath; click closes whichever surface is up. */}
|
|
1137
|
+
<div
|
|
1138
|
+
className="velu-docs-layout__scrim"
|
|
1139
|
+
aria-hidden="true"
|
|
1140
|
+
onClick={() => {
|
|
1141
|
+
setDrawerOpen(false);
|
|
1142
|
+
setChatOpen(false);
|
|
1143
|
+
}}
|
|
1144
|
+
/>
|
|
1145
|
+
{/* Site header — brand, centered search, right-side actions,
|
|
1146
|
+
tabs row. Configurable: pass any number of actions / tabs. */}
|
|
1147
|
+
<PageHeader
|
|
1148
|
+
linkComponent={RouterLink}
|
|
1149
|
+
brand={{
|
|
1150
|
+
label: site.name,
|
|
1151
|
+
href: site.logo?.href || '/',
|
|
1152
|
+
logo: site.logo,
|
|
1153
|
+
}}
|
|
1154
|
+
brandTrailing={
|
|
1155
|
+
versionSwitcher && (
|
|
1156
|
+
<span className="velu-hide-on-mobile">{versionSwitcher}</span>
|
|
1157
|
+
)
|
|
1158
|
+
}
|
|
1159
|
+
tabsTrailing={languageSwitcher || undefined}
|
|
1160
|
+
center={
|
|
1161
|
+
<Cluster space="var(--s-6)" align="center">
|
|
1162
|
+
<Search
|
|
1163
|
+
style={{ inlineSize: '30ch' }}
|
|
1164
|
+
unavailable={IS_DEV_PREVIEW}
|
|
1165
|
+
search={IS_DEV_PREVIEW ? undefined : searchDocs}
|
|
1166
|
+
onSelect={(item) => item.href && navigate(item.href)}
|
|
1167
|
+
/>
|
|
1168
|
+
{/* Ask AI talks to the deployed site's AI backend — hidden in
|
|
1169
|
+
the local dev preview where there's nothing to talk to. */}
|
|
1170
|
+
{!IS_DEV_PREVIEW && (
|
|
1171
|
+
<button
|
|
1172
|
+
type="button"
|
|
1173
|
+
className="velu-header__action velu-header__action--outlined"
|
|
1174
|
+
onClick={() => askAI('')}
|
|
1175
|
+
>
|
|
1176
|
+
<span className="velu-header__action-icon" aria-hidden="true">
|
|
1177
|
+
{resolveIcon('sparkles', { size: '1.5em' })}
|
|
1178
|
+
</span>
|
|
1179
|
+
<span>Ask AI</span>
|
|
1180
|
+
</button>
|
|
1181
|
+
)}
|
|
1182
|
+
</Cluster>
|
|
1183
|
+
}
|
|
1184
|
+
actions={navActions}
|
|
1185
|
+
trailing={<ThemeToggle />}
|
|
1186
|
+
onMenuClick={() => setDrawerOpen((v) => !v)}
|
|
1187
|
+
breadcrumb={nav?.breadcrumb ?? []}
|
|
1188
|
+
activeTab={nav?.activeTab}
|
|
1189
|
+
tabs={nav?.tabs ?? []}
|
|
1190
|
+
/>
|
|
1191
|
+
|
|
1192
|
+
{/* Fixed left sidebar — pinned to viewport-left below the header.
|
|
1193
|
+
Does NOT scroll with the page; the footer rises over its bottom
|
|
1194
|
+
edge thanks to the higher z-index on the footer below.
|
|
1195
|
+
`top` + `bottom` give a robust height (some browsers don't
|
|
1196
|
+
honour `inset-block-start` for fixed positioning the same way
|
|
1197
|
+
as plain `top`). */}
|
|
1198
|
+
<aside
|
|
1199
|
+
className="velu-docs-layout__aside velu-docs-layout__aside--left"
|
|
1200
|
+
style={{
|
|
1201
|
+
/* Bottom edge stays a fixed gap above the viewport bottom,
|
|
1202
|
+
AND lifts to keep that gap above the footer as it scrolls
|
|
1203
|
+
into view (footerOverlap = how far the footer intrudes).
|
|
1204
|
+
Set as a custom prop so the mobile drawer's
|
|
1205
|
+
`inset-block-end: 0` override still wins. */
|
|
1206
|
+
'--velu-aside-bottom': `calc(${footerOverlap}px + var(--s4))`,
|
|
1207
|
+
}}
|
|
1208
|
+
>
|
|
1209
|
+
{/* Drawer body — Stack with 32px gap composes the slots
|
|
1210
|
+
(topbar / tab-dropdown / context / nav). The aside is a flex
|
|
1211
|
+
column (CSS): the pinned slots keep their height and only the
|
|
1212
|
+
nav region scrolls (see velu-docs-nav-scroll below). At wide
|
|
1213
|
+
widths the topbar + docselect are `display: none`. */}
|
|
1214
|
+
<Stack space="var(--s0)" className="velu-docs-aside-stack">
|
|
1215
|
+
<div className="velu-docs-layout__drawer-head">
|
|
1216
|
+
<RouterLink
|
|
1217
|
+
href="/"
|
|
1218
|
+
className="velu-docs-layout__drawer-brand"
|
|
1219
|
+
>
|
|
1220
|
+
{site.logo ? (
|
|
1221
|
+
<Logo logo={site.logo} alt={site.name} />
|
|
1222
|
+
) : (
|
|
1223
|
+
<>
|
|
1224
|
+
<VeluMark />
|
|
1225
|
+
<span className="velu-header__wordmark">{site.name}</span>
|
|
1226
|
+
</>
|
|
1227
|
+
)}
|
|
1228
|
+
</RouterLink>
|
|
1229
|
+
<ThemeToggle />
|
|
1230
|
+
<button
|
|
1231
|
+
type="button"
|
|
1232
|
+
className="velu-docs-layout__drawer-close"
|
|
1233
|
+
aria-label="Close navigation"
|
|
1234
|
+
onClick={() => setDrawerOpen(false)}
|
|
1235
|
+
>
|
|
1236
|
+
<X aria-hidden="true" focusable="false" />
|
|
1237
|
+
</button>
|
|
1238
|
+
</div>
|
|
1239
|
+
{/* Nav dropdown — custom button + menu, fills drawer width.
|
|
1240
|
+
Items mirror the desktop tabs (Home / Docs / Blog).
|
|
1241
|
+
Click-outside + Escape close (see navOpen useEffect
|
|
1242
|
+
above). Chevron rotates 180° on open. */}
|
|
1243
|
+
<div
|
|
1244
|
+
ref={navRef}
|
|
1245
|
+
className="velu-docs-layout__drawer-docselect"
|
|
1246
|
+
data-open={navOpen ? 'true' : 'false'}
|
|
1247
|
+
>
|
|
1248
|
+
<button
|
|
1249
|
+
type="button"
|
|
1250
|
+
className="velu-docs-layout__drawer-docselect-btn"
|
|
1251
|
+
onClick={() => setNavOpen((o) => !o)}
|
|
1252
|
+
aria-haspopup="menu"
|
|
1253
|
+
aria-expanded={navOpen}
|
|
1254
|
+
>
|
|
1255
|
+
<span className="velu-docs-layout__drawer-docselect-label">
|
|
1256
|
+
{activeNavLabel}
|
|
1257
|
+
</span>
|
|
1258
|
+
<ChevronDown
|
|
1259
|
+
className="velu-docs-layout__drawer-docselect-chev"
|
|
1260
|
+
aria-hidden="true"
|
|
1261
|
+
focusable="false"
|
|
1262
|
+
/>
|
|
1263
|
+
</button>
|
|
1264
|
+
<ul
|
|
1265
|
+
className="velu-docs-layout__drawer-docselect-menu"
|
|
1266
|
+
role="menu"
|
|
1267
|
+
aria-hidden={!navOpen}
|
|
1268
|
+
>
|
|
1269
|
+
{navItems.map((it) => (
|
|
1270
|
+
<li key={it.href} role="none">
|
|
1271
|
+
<a
|
|
1272
|
+
role="menuitem"
|
|
1273
|
+
className="velu-docs-layout__drawer-docselect-item"
|
|
1274
|
+
href={it.href}
|
|
1275
|
+
tabIndex={navOpen ? 0 : -1}
|
|
1276
|
+
onClick={() => setNavOpen(false)}
|
|
1277
|
+
>
|
|
1278
|
+
{it.label}
|
|
1279
|
+
</a>
|
|
1280
|
+
</li>
|
|
1281
|
+
))}
|
|
1282
|
+
</ul>
|
|
1283
|
+
</div>
|
|
1284
|
+
{/* Context zone — product switcher + anchors at the top of
|
|
1285
|
+
the sidebar (all breakpoints), plus version/language
|
|
1286
|
+
switchers that only show on mobile (desktop has them in
|
|
1287
|
+
the header). Rendered only when there's something to show
|
|
1288
|
+
so simple projects keep a bare sidebar. */}
|
|
1289
|
+
{(productSwitcher ||
|
|
1290
|
+
versionSwitcher ||
|
|
1291
|
+
languageSwitcher ||
|
|
1292
|
+
anchors.length > 0) && (
|
|
1293
|
+
<Stack space="var(--s-1)" className="velu-docs-context">
|
|
1294
|
+
{productSwitcher}
|
|
1295
|
+
{versionSwitcher && (
|
|
1296
|
+
<span className="velu-show-on-mobile">{versionSwitcher}</span>
|
|
1297
|
+
)}
|
|
1298
|
+
{languageSwitcher && (
|
|
1299
|
+
<span className="velu-show-on-mobile">{languageSwitcher}</span>
|
|
1300
|
+
)}
|
|
1301
|
+
{anchors.length > 0 && (
|
|
1302
|
+
<ul className="velu-docs-anchors">
|
|
1303
|
+
{anchors.map((a, i) => (
|
|
1304
|
+
<li key={i}>
|
|
1305
|
+
<a
|
|
1306
|
+
className="velu-docs-anchors__link"
|
|
1307
|
+
href={a.href}
|
|
1308
|
+
target="_blank"
|
|
1309
|
+
rel="noreferrer"
|
|
1310
|
+
>
|
|
1311
|
+
{a.icon && (
|
|
1312
|
+
<span
|
|
1313
|
+
className="velu-docs-anchors__icon"
|
|
1314
|
+
aria-hidden="true"
|
|
1315
|
+
>
|
|
1316
|
+
{resolveIcon(a.icon, { size: '1em' })}
|
|
1317
|
+
</span>
|
|
1318
|
+
)}
|
|
1319
|
+
<span>{a.label}</span>
|
|
1320
|
+
</a>
|
|
1321
|
+
</li>
|
|
1322
|
+
))}
|
|
1323
|
+
</ul>
|
|
1324
|
+
)}
|
|
1325
|
+
</Stack>
|
|
1326
|
+
)}
|
|
1327
|
+
{/* Hairline separating the top anchor links from the nav sections
|
|
1328
|
+
(faithful to the sidebar design's anchor↔sidebar divider). */}
|
|
1329
|
+
{anchors.length > 0 && (
|
|
1330
|
+
<div className="velu-docs-context-divider" aria-hidden="true" />
|
|
1331
|
+
)}
|
|
1332
|
+
{/* Only this region scrolls — the context zone above stays
|
|
1333
|
+
pinned. The up/down arrows overlay its top/bottom edges and
|
|
1334
|
+
appear (via the data-fade-* attrs the scroll handler sets)
|
|
1335
|
+
when there's content beyond that edge; clicking jumps the
|
|
1336
|
+
nav fully to that end. */}
|
|
1337
|
+
<div className="velu-docs-nav-region">
|
|
1338
|
+
<div
|
|
1339
|
+
ref={leftAsideRef}
|
|
1340
|
+
className="velu-docs-nav-scroll velu-hide-scrollbar"
|
|
1341
|
+
style={{
|
|
1342
|
+
/* Just breathing room — the aside's bottom edge already
|
|
1343
|
+
stays above the footer (see --velu-aside-bottom). */
|
|
1344
|
+
paddingBlockEnd: 'var(--s1)',
|
|
1345
|
+
scrollPaddingBlockEnd: 'var(--s1)',
|
|
1346
|
+
}}
|
|
1347
|
+
>
|
|
1348
|
+
{entry?.api ? (
|
|
1349
|
+
<ApiSidebar
|
|
1350
|
+
sections={(nav?.sidebarSections ?? []).map((s) => ({
|
|
1351
|
+
title: s.title,
|
|
1352
|
+
icon: s.icon,
|
|
1353
|
+
endpoints: (s.items ?? []).map((it) => ({
|
|
1354
|
+
method: it.method,
|
|
1355
|
+
label: it.label,
|
|
1356
|
+
href: it.href,
|
|
1357
|
+
})),
|
|
1358
|
+
}))}
|
|
1359
|
+
activeHref={pathname}
|
|
1360
|
+
linkComponent={RouterLink}
|
|
1361
|
+
/>
|
|
1362
|
+
) : (
|
|
1363
|
+
<Sidebar
|
|
1364
|
+
sections={nav?.sidebarSections ?? []}
|
|
1365
|
+
activeHref={pathname}
|
|
1366
|
+
linkComponent={RouterLink}
|
|
1367
|
+
/>
|
|
1368
|
+
)}
|
|
1369
|
+
</div>
|
|
1370
|
+
<button
|
|
1371
|
+
type="button"
|
|
1372
|
+
className="velu-docs-nav-arrow velu-docs-nav-arrow--up"
|
|
1373
|
+
aria-label="Scroll navigation to top"
|
|
1374
|
+
onClick={() =>
|
|
1375
|
+
leftAsideRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
|
1376
|
+
}
|
|
1377
|
+
>
|
|
1378
|
+
<ChevronUp aria-hidden="true" focusable="false" />
|
|
1379
|
+
</button>
|
|
1380
|
+
<button
|
|
1381
|
+
type="button"
|
|
1382
|
+
className="velu-docs-nav-arrow velu-docs-nav-arrow--down"
|
|
1383
|
+
aria-label="Scroll navigation to bottom"
|
|
1384
|
+
onClick={() =>
|
|
1385
|
+
leftAsideRef.current?.scrollTo({
|
|
1386
|
+
top: leftAsideRef.current.scrollHeight,
|
|
1387
|
+
behavior: 'smooth',
|
|
1388
|
+
})
|
|
1389
|
+
}
|
|
1390
|
+
>
|
|
1391
|
+
<ChevronDown aria-hidden="true" focusable="false" />
|
|
1392
|
+
</button>
|
|
1393
|
+
</div>
|
|
1394
|
+
</Stack>
|
|
1395
|
+
</aside>
|
|
1396
|
+
|
|
1397
|
+
{/* No separate rail element — at narrow widths the sidebar's
|
|
1398
|
+
aside ANIMATES its `inline-size` between 240px (open) and
|
|
1399
|
+
--velu-rail-width (closed), and its inner Sidebar nav fades
|
|
1400
|
+
out via opacity. The aside's border-inline-end therefore
|
|
1401
|
+
becomes the rail's right border when collapsed. This makes
|
|
1402
|
+
the collapse a smooth width+opacity transition instead of a
|
|
1403
|
+
display:none snap. See docs-layout.css. */}
|
|
1404
|
+
|
|
1405
|
+
{/* Fixed right TOC — pinned to viewport-right. Hidden while the
|
|
1406
|
+
Ask-AI chatbot is open (the panel takes that edge). */}
|
|
1407
|
+
{!chatOpen && (
|
|
1408
|
+
<aside
|
|
1409
|
+
ref={rightAsideRef}
|
|
1410
|
+
className="velu-docs-layout__aside velu-docs-layout__aside--right velu-hide-scrollbar"
|
|
1411
|
+
style={{
|
|
1412
|
+
/* Dynamic footer-overlap padding only — static geometry
|
|
1413
|
+
lives in docs-layout.css. */
|
|
1414
|
+
paddingBlockEnd: `calc(var(--s3) + ${footerOverlap}px + 2rem)`,
|
|
1415
|
+
scrollPaddingBlockEnd: `calc(${footerOverlap}px + 2rem)`,
|
|
1416
|
+
}}
|
|
1417
|
+
>
|
|
1418
|
+
{/* API reference pages put their code samples in the right rail
|
|
1419
|
+
(where the TOC sits for normal pages). */}
|
|
1420
|
+
{entry?.api && entry.operation ? (
|
|
1421
|
+
<ApiSamples
|
|
1422
|
+
samples={entry.samples}
|
|
1423
|
+
responses={entry.operation.responses}
|
|
1424
|
+
/>
|
|
1425
|
+
) : (
|
|
1426
|
+
<Toc items={toc} activeId={activeId} onSelect={scrollTo} />
|
|
1427
|
+
)}
|
|
1428
|
+
</aside>
|
|
1429
|
+
)}
|
|
1430
|
+
|
|
1431
|
+
{/* Center column. Margin to reserve aside space lives in
|
|
1432
|
+
docs-layout.css (drops to 0 at narrow widths via @container,
|
|
1433
|
+
and the right margin collapses to 0 while the chatbot is
|
|
1434
|
+
open via [data-chat-open="true"]). */}
|
|
1435
|
+
<div className="velu-docs-layout__center">
|
|
1436
|
+
{isNotFound ? (
|
|
1437
|
+
<main className="velu-404-main">
|
|
1438
|
+
<NotFound
|
|
1439
|
+
homeHref="/"
|
|
1440
|
+
linkComponent={RouterLink}
|
|
1441
|
+
onSearch={() =>
|
|
1442
|
+
window.dispatchEvent(
|
|
1443
|
+
new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, metaKey: true }),
|
|
1444
|
+
)
|
|
1445
|
+
}
|
|
1446
|
+
onAskAI={IS_DEV_PREVIEW ? undefined : () => askAI('')}
|
|
1447
|
+
/>
|
|
1448
|
+
</main>
|
|
1449
|
+
) : (
|
|
1450
|
+
<>
|
|
1451
|
+
{/* Narrow-layout TOC bar — always in DOM, only visible at
|
|
1452
|
+
< 1024px (toggled by @container in toc-bar.css). Shares
|
|
1453
|
+
its `items` + `activeId` + `onSelect` API with the
|
|
1454
|
+
right-rail <Toc> so both views are driven by the same
|
|
1455
|
+
data; whichever one is visible at a given width responds
|
|
1456
|
+
to the same scroll-spy state. */}
|
|
1457
|
+
<TocBar items={toc} activeId={activeId} onSelect={scrollTo} />
|
|
1458
|
+
<main
|
|
1459
|
+
className="velu-docs-layout__main"
|
|
1460
|
+
style={{
|
|
1461
|
+
/* No padding-block-end — the article's last child (PoweredBy)
|
|
1462
|
+
owns the gap to the footer via its own margin-bottom, so
|
|
1463
|
+
padding-bottom here would double-count it. `position:
|
|
1464
|
+
relative` makes <main> the offsetParent for the absolutely
|
|
1465
|
+
positioned sidebar-toggle below — so its `top: 0` lands
|
|
1466
|
+
at the top of the article area, BELOW the sticky TocBar
|
|
1467
|
+
(which lives outside <main>). Padding-inline lives in CSS
|
|
1468
|
+
so the @container query can collapse the start side when
|
|
1469
|
+
the sidebar is closed (no "ghost column" on the left). */
|
|
1470
|
+
position: 'relative',
|
|
1471
|
+
color: 'var(--text-color)',
|
|
1472
|
+
}}
|
|
1473
|
+
>
|
|
1474
|
+
{/* Sidebar toggle — narrow-layout only (hidden by @container
|
|
1475
|
+
at wide widths). Lives inside <main> so its absolute
|
|
1476
|
+
position is relative to the article area, not the centre
|
|
1477
|
+
column, and therefore sits BELOW the TocBar rather than
|
|
1478
|
+
overlapping it. Chevron flips direction with the
|
|
1479
|
+
`data-sidebar-open` data attribute on the layout root. */}
|
|
1480
|
+
<button
|
|
1481
|
+
type="button"
|
|
1482
|
+
className="velu-docs-layout__sidebar-toggle"
|
|
1483
|
+
onClick={() => setSidebarOpen((v) => !v)}
|
|
1484
|
+
aria-label={sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
|
1485
|
+
aria-expanded={sidebarOpen}
|
|
1486
|
+
>
|
|
1487
|
+
<span aria-hidden="true">
|
|
1488
|
+
{resolveIcon(sidebarOpen ? 'chevron-left' : 'chevron-right', {
|
|
1489
|
+
size: '1em',
|
|
1490
|
+
})}
|
|
1491
|
+
</span>
|
|
1492
|
+
</button>
|
|
1493
|
+
<div className="velu-docs-layout__article" data-pagefind-body="">
|
|
1494
|
+
{/* Per-page agent/IDE action bar: the section eyebrow + a
|
|
1495
|
+
"Copy Page" split-button whose dropdown is driven by the
|
|
1496
|
+
Mintlify-compatible `contextual` config. Renders nothing
|
|
1497
|
+
when there's neither an eyebrow nor any enabled options. */}
|
|
1498
|
+
<ContextMenu
|
|
1499
|
+
eyebrow={
|
|
1500
|
+
nav?.breadcrumb && nav.breadcrumb.length > 1
|
|
1501
|
+
? nav.breadcrumb[nav.breadcrumb.length - 2].label
|
|
1502
|
+
: undefined
|
|
1503
|
+
}
|
|
1504
|
+
pageUrl={pathname}
|
|
1505
|
+
title={frontmatter.title}
|
|
1506
|
+
isApi={entry?.api === true}
|
|
1507
|
+
siteName={site.name}
|
|
1508
|
+
options={site.contextual?.options ?? []}
|
|
1509
|
+
onAssistant={IS_DEV_PREVIEW ? undefined : () => askAI('')}
|
|
1510
|
+
getMarkdown={entry?.getSource}
|
|
1511
|
+
/>
|
|
1512
|
+
|
|
1513
|
+
{/* Page hero from MDX frontmatter. `.velu-hero` rules
|
|
1514
|
+
(in base.css) keep the title and description tightly
|
|
1515
|
+
grouped and create a clear break before the prose body. */}
|
|
1516
|
+
{(frontmatter.title || frontmatter.description) && (
|
|
1517
|
+
<div className="velu-hero">
|
|
1518
|
+
{frontmatter.title && <h1 id={pageId}>{frontmatter.title}</h1>}
|
|
1519
|
+
{frontmatter.description && (
|
|
1520
|
+
<p
|
|
1521
|
+
style={{
|
|
1522
|
+
color: 'var(--muted-color)',
|
|
1523
|
+
fontSize: 'var(--f-h4)',
|
|
1524
|
+
lineHeight: 'var(--lh-h4)',
|
|
1525
|
+
}}
|
|
1526
|
+
>
|
|
1527
|
+
{frontmatter.description}
|
|
1528
|
+
</p>
|
|
1529
|
+
)}
|
|
1530
|
+
</div>
|
|
1531
|
+
)}
|
|
1532
|
+
|
|
1533
|
+
{/* Article body — the current route's MDX page, rendered
|
|
1534
|
+
through the velu-ui MDX component registry. `velu-prose`
|
|
1535
|
+
gives a consistent vertical rhythm scaled by heading
|
|
1536
|
+
level. Falls back to a not-found / missing-file notice
|
|
1537
|
+
when the route has no page (or its file is absent). */}
|
|
1538
|
+
<MDXProvider components={defaultMdxComponents}>
|
|
1539
|
+
<div className="velu-prose">
|
|
1540
|
+
{PageComponent ? (
|
|
1541
|
+
<ErrorBoundary key={pathname} file={entry?.relPath}>
|
|
1542
|
+
<PageComponent />
|
|
1543
|
+
</ErrorBoundary>
|
|
1544
|
+
) : (
|
|
1545
|
+
<p style={{ color: 'var(--muted-color)' }}>
|
|
1546
|
+
{entry?.missing
|
|
1547
|
+
? 'This page is listed in navigation but its .mdx file was not found.'
|
|
1548
|
+
: 'Page not found.'}
|
|
1549
|
+
</p>
|
|
1550
|
+
)}
|
|
1551
|
+
</div>
|
|
1552
|
+
</MDXProvider>
|
|
1553
|
+
{/* Page-foot feedback widget — ref'd so the sticky AskBar
|
|
1554
|
+
above can hide as the user scrolls near it. */}
|
|
1555
|
+
<div ref={feedbackRef} style={{ marginTop: 'var(--s3)' }}>
|
|
1556
|
+
<PageFeedback />
|
|
1557
|
+
</div>
|
|
1558
|
+
{/* Previous / next page navigation — derived from the
|
|
1559
|
+
sidebar reading order of the active section. */}
|
|
1560
|
+
{(nav?.prev || nav?.next) && (
|
|
1561
|
+
<PageNav
|
|
1562
|
+
style={{ marginTop: 'var(--s2)' }}
|
|
1563
|
+
prev={nav?.prev}
|
|
1564
|
+
next={nav?.next}
|
|
1565
|
+
linkComponent={RouterLink}
|
|
1566
|
+
/>
|
|
1567
|
+
)}
|
|
1568
|
+
{/* "Ask a question" bar — sticky to viewport-bottom while the
|
|
1569
|
+
article column is in view; lives inside the prose-column
|
|
1570
|
+
div so its width matches the article. Submitting opens
|
|
1571
|
+
the Ask-AI chatbot; fades out as the PageFeedback widget
|
|
1572
|
+
approaches (see IntersectionObserver above). */}
|
|
1573
|
+
{/* Ask-a-question bar — opens the AI chatbot, which runs on the
|
|
1574
|
+
deployed site. Hidden in the local dev preview. */}
|
|
1575
|
+
{!IS_DEV_PREVIEW && !chatOpen && (
|
|
1576
|
+
<AskBar
|
|
1577
|
+
onSubmit={askAI}
|
|
1578
|
+
style={{
|
|
1579
|
+
position: 'sticky',
|
|
1580
|
+
insetBlockEnd: 'var(--s1)',
|
|
1581
|
+
/* Once the user has scrolled near the feedback widget the
|
|
1582
|
+
AskBar fades and collapses to zero flow space — height,
|
|
1583
|
+
margin, padding all drop to 0 so the next element
|
|
1584
|
+
(PoweredBy) sits directly under PageNav at its own
|
|
1585
|
+
32px margin, with no phantom gap. */
|
|
1586
|
+
marginTop: askBarHidden ? 0 : 'var(--s2)',
|
|
1587
|
+
height: askBarHidden ? 0 : 'auto',
|
|
1588
|
+
overflow: 'hidden',
|
|
1589
|
+
opacity: askBarHidden ? 0 : 1,
|
|
1590
|
+
transform: askBarHidden ? 'translateY(20%)' : 'translateY(0)',
|
|
1591
|
+
pointerEvents: askBarHidden ? 'none' : 'auto',
|
|
1592
|
+
transition: 'opacity 0.2s ease, transform 0.2s ease',
|
|
1593
|
+
}}
|
|
1594
|
+
/>
|
|
1595
|
+
)}
|
|
1596
|
+
{/* Content foot — "Powered by Velu" at the bottom-right of the
|
|
1597
|
+
article column. When the footer has no link columns, the
|
|
1598
|
+
configured social icons sit on the LEFT of this same row (no
|
|
1599
|
+
full footer section); with link columns they live in the full
|
|
1600
|
+
footer below instead. */}
|
|
1601
|
+
{footerHasLinks ? (
|
|
1602
|
+
<PoweredBy />
|
|
1603
|
+
) : (
|
|
1604
|
+
<div className="velu-content-foot">
|
|
1605
|
+
<SocialLinks socials={footerSocials} />
|
|
1606
|
+
<PoweredBy />
|
|
1607
|
+
</div>
|
|
1608
|
+
)}
|
|
1609
|
+
</div>
|
|
1610
|
+
</main>
|
|
1611
|
+
</>
|
|
1612
|
+
)}
|
|
1613
|
+
</div>
|
|
1614
|
+
|
|
1615
|
+
{/* Full site footer — only when the config provides link columns. Spans
|
|
1616
|
+
full width below the article; its raised z-index eclipses the bottoms
|
|
1617
|
+
of the fixed sidebar and TOC as the page scrolls into it, and the ref
|
|
1618
|
+
is watched so the asides can pad their bottom by the overlap. */}
|
|
1619
|
+
{footerHasLinks && (
|
|
1620
|
+
<div
|
|
1621
|
+
ref={footerRef}
|
|
1622
|
+
data-velu-footer
|
|
1623
|
+
style={{ position: 'relative', zIndex: 20 }}
|
|
1624
|
+
>
|
|
1625
|
+
<PageFooter
|
|
1626
|
+
brand={{ href: site.logo?.href || '/' }}
|
|
1627
|
+
columns={site.footer.links}
|
|
1628
|
+
socials={footerSocials}
|
|
1629
|
+
/>
|
|
1630
|
+
</div>
|
|
1631
|
+
)}
|
|
1632
|
+
|
|
1633
|
+
{/* Ask-AI chatbot — slides in from the inline-end edge. */}
|
|
1634
|
+
<Chatbot
|
|
1635
|
+
open={chatOpen}
|
|
1636
|
+
seedQuestion={chatQuestion}
|
|
1637
|
+
onClose={() => setChatOpen(false)}
|
|
1638
|
+
ask={assistant?.ask}
|
|
1639
|
+
onFeedback={assistant?.sendFeedback}
|
|
1640
|
+
listHistory={assistant?.listConversations}
|
|
1641
|
+
loadConversation={assistant?.loadConversation}
|
|
1642
|
+
suggestions={site.assistant?.suggestions}
|
|
1643
|
+
onNavigate={(to) => {
|
|
1644
|
+
navigate(to);
|
|
1645
|
+
// After the destination page mounts, scroll to the cited section
|
|
1646
|
+
// (its heading id) — or the top of the page when there's no anchor.
|
|
1647
|
+
const hash = to.includes('#') ? to.slice(to.indexOf('#') + 1) : '';
|
|
1648
|
+
let tries = 0;
|
|
1649
|
+
const go = () => {
|
|
1650
|
+
if (hash) {
|
|
1651
|
+
if (document.getElementById(hash)) { scrollTo(hash); return; }
|
|
1652
|
+
if (tries++ < 10) { setTimeout(go, 60); return; }
|
|
1653
|
+
}
|
|
1654
|
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
1655
|
+
};
|
|
1656
|
+
setTimeout(go, 50);
|
|
1657
|
+
}}
|
|
1658
|
+
/>
|
|
1659
|
+
</div>
|
|
1660
|
+
);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
export default function App() {
|
|
1664
|
+
return (
|
|
1665
|
+
<Routes>
|
|
1666
|
+
<Route path="*" element={<DocsPage />} />
|
|
1667
|
+
</Routes>
|
|
1668
|
+
);
|
|
1669
|
+
}
|