@veluai/velu 0.1.16 → 0.2.1

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.
@@ -0,0 +1,113 @@
1
+ // Pagefind client — queries the static search index that `velu build` writes to
2
+ // /pagefind/. The index is a chunked, lazy-loaded WASM bundle: the browser only
3
+ // downloads the fragments it needs per query, so search scales to huge sites
4
+ // without shipping one giant index. Generated at build time; absent in dev
5
+ // (the dev preview shows search as "unavailable").
6
+ //
7
+ // The runtime is loaded via a NON-analyzable dynamic import (a Function-wrapped
8
+ // import) so neither Vite nor the bundler tries to resolve /pagefind/pagefind.js
9
+ // at build time — it only exists in the deployed output.
10
+
11
+ let _pf = null;
12
+ let _loading = null;
13
+
14
+ async function loadRuntime() {
15
+ if (_pf) return _pf;
16
+ if (_loading) return _loading;
17
+ _loading = (async () => {
18
+ const importer = new Function('p', 'return import(p)');
19
+ const mod = await importer('/pagefind/pagefind.js');
20
+ if (typeof mod.init === 'function') await mod.init();
21
+ _pf = mod;
22
+ return mod;
23
+ })();
24
+ try {
25
+ return await _loading;
26
+ } catch (err) {
27
+ _loading = null;
28
+ throw err;
29
+ }
30
+ }
31
+
32
+ const stripHtml = (s) => String(s || '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
33
+
34
+ // Pagefind returns directory URLs (/quickstart/, /a/b/, /); normalize to the
35
+ // router's path form (/quickstart, /a/b, /). Preserve any #anchor.
36
+ function normalizeUrl(url) {
37
+ if (!url) return '/';
38
+ let [path, hash] = String(url).split('#');
39
+ path = path.replace(/index\.html?$/i, '').replace(/\.html?$/i, '');
40
+ if (path.length > 1) path = path.replace(/\/+$/, '');
41
+ if (!path) path = '/';
42
+ return hash ? `${path}#${hash}` : path;
43
+ }
44
+
45
+ // Breadcrumb from the URL path (everything but the leaf), title-cased.
46
+ function crumbsFromUrl(url) {
47
+ const segs = String(url).split('#')[0].split('/').filter(Boolean);
48
+ segs.pop();
49
+ return segs.map((s) => s.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()));
50
+ }
51
+
52
+ /**
53
+ * Run a query against the static Pagefind index.
54
+ * @returns {Promise<Array<SearchResult>|null>} palette-shaped results, or null
55
+ * when the index can't be loaded (→ caller can show "unavailable").
56
+ */
57
+ export default async function pagefindSearch(query) {
58
+ const q = String(query || '').trim();
59
+ if (!q) return [];
60
+
61
+ let pf;
62
+ try {
63
+ pf = await loadRuntime();
64
+ } catch {
65
+ return null; // index not present (e.g. not built) → unavailable
66
+ }
67
+
68
+ let response;
69
+ try {
70
+ response = await pf.search(q);
71
+ } catch {
72
+ return [];
73
+ }
74
+
75
+ const hits = await Promise.all(response.results.slice(0, 8).map((r) => r.data()));
76
+ const out = [];
77
+ const seen = new Set(); // dedupe by href
78
+ for (const hit of hits) {
79
+ const href = normalizeUrl(hit.url);
80
+ const title = hit.meta?.title || crumbsFromUrl(hit.url).pop() || href;
81
+ if (!seen.has(href)) {
82
+ seen.add(href);
83
+ out.push({
84
+ id: href,
85
+ group: 'Pages',
86
+ breadcrumb: crumbsFromUrl(hit.url),
87
+ title,
88
+ kind: 'page',
89
+ desc: stripHtml(hit.excerpt),
90
+ href,
91
+ });
92
+ }
93
+ // Heading-level matches → deep-linkable anchor rows. Skip the page's own
94
+ // top section (pagefind repeats it as a sub-result with the page title).
95
+ for (const sub of hit.sub_results || []) {
96
+ const subHref = normalizeUrl(sub.url);
97
+ if (seen.has(subHref) || !subHref.includes('#') || sub.title === title) continue;
98
+ seen.add(subHref);
99
+ out.push({
100
+ id: subHref,
101
+ group: 'Pages',
102
+ breadcrumb: [title],
103
+ title: sub.title || title,
104
+ kind: 'anchor',
105
+ desc: stripHtml(sub.excerpt),
106
+ href: subHref,
107
+ });
108
+ if (out.length >= 12) break;
109
+ }
110
+ if (out.length >= 12) break;
111
+ }
112
+ return out;
113
+ }
@@ -21,6 +21,7 @@
21
21
  @import './primitives/switcher.css';
22
22
  @import './components/sidebar.css';
23
23
  @import './components/nav-select.css';
24
+ @import './components/context-menu.css';
24
25
  @import './components/accordion.css';
25
26
  @import './components/card.css';
26
27
  @import './components/image.css';
@@ -31,6 +32,7 @@
31
32
  @import './components/tree.css';
32
33
  @import './components/api.css';
33
34
  @import './components/api-page.css';
35
+ @import './components/not-found.css';
34
36
  @import './components/ask-bar.css';
35
37
  @import './components/chatbot.css';
36
38
  @import './components/page-feedback.css';
@@ -40,43 +40,7 @@
40
40
  "type": "string",
41
41
  "description": "Path to the favicon, relative to the project root."
42
42
  },
43
- "api": {
44
- "type": "object",
45
- "additionalProperties": false,
46
- "description": "API reference behaviour for OpenAPI-generated pages.",
47
- "properties": {
48
- "server": {
49
- "type": "string",
50
- "description": "Base URL override for the playground + code samples (otherwise the spec's first server is used)."
51
- },
52
- "playground": {
53
- "type": "object",
54
- "additionalProperties": false,
55
- "properties": {
56
- "display": {
57
- "type": "string",
58
- "enum": ["interactive", "simple", "none"],
59
- "description": "Playground mode. \"none\" disables the proxy / live send."
60
- },
61
- "proxy": {
62
- "type": "boolean",
63
- "description": "Send Try-It requests through the dev proxy to avoid CORS (default true)."
64
- }
65
- }
66
- },
67
- "examples": {
68
- "type": "object",
69
- "additionalProperties": false,
70
- "properties": {
71
- "languages": {
72
- "type": "array",
73
- "items": { "type": "string", "enum": ["curl", "javascript", "python", "ruby"] },
74
- "description": "Which request-snippet languages to generate."
75
- }
76
- }
77
- }
78
- }
79
- },
43
+ "api": { "$ref": "#/$defs/api" },
80
44
  "logo": {
81
45
  "description": "Site logo shown in the header, replacing the name wordmark. A single path used in both themes, or per-theme light/dark images with an optional click-through href. Paths are relative to the project root.",
82
46
  "oneOf": [
@@ -206,6 +170,78 @@
206
170
  "navigation": {
207
171
  "$ref": "#/$defs/navContainer",
208
172
  "description": "Site navigation. Mintlify-compatible: products > versions > languages > tabs > groups > pages. Switchable axes (product/version/language) become URL path prefixes; the default value of each is unprefixed. Dropdowns are intentionally unsupported."
173
+ },
174
+ "url": {
175
+ "type": "string",
176
+ "description": "Production site origin (e.g. \"https://docs.example.com\"), no trailing slash. Used by the static build for canonical URLs, og:url, and sitemap.xml, and by llms.txt for absolute links."
177
+ },
178
+ "thumbnails": {
179
+ "type": "object",
180
+ "additionalProperties": false,
181
+ "description": "Auto-generated OG (social share) image options. Build-only.",
182
+ "properties": {
183
+ "background": {
184
+ "type": "string",
185
+ "description": "Project-relative path to a background image for the OG cards (e.g. \"/images/og-background.png\"). Defaults to a brand-color glow."
186
+ }
187
+ }
188
+ },
189
+ "seo": {
190
+ "type": "object",
191
+ "additionalProperties": false,
192
+ "description": "Search-engine optimization. Build-only: meta tags and sitemap behaviour applied when the site is built (not in the dev preview).",
193
+ "properties": {
194
+ "metatags": {
195
+ "type": "object",
196
+ "description": "Extra meta tags injected on every page, as a flat key→value map (e.g. \"google-site-verification\", \"og:image\").",
197
+ "additionalProperties": { "type": "string" }
198
+ },
199
+ "indexing": {
200
+ "type": "string",
201
+ "enum": ["navigable", "all"],
202
+ "description": "\"navigable\" (default) indexes only pages in navigation; \"all\" also includes hidden pages."
203
+ }
204
+ }
205
+ },
206
+ "contextual": {
207
+ "type": "object",
208
+ "additionalProperties": false,
209
+ "description": "Per-page context menu — the \"Copy Page\" split-button + dropdown of agent/IDE actions shown at the top of each page. Mintlify-compatible. Unset → copy, view, chatgpt, claude.",
210
+ "required": ["options"],
211
+ "properties": {
212
+ "options": {
213
+ "type": "array",
214
+ "description": "Menu items, in order. Known string options, or custom { title, description, href } entries.",
215
+ "items": {
216
+ "oneOf": [
217
+ {
218
+ "type": "string",
219
+ "enum": [
220
+ "copy", "assistant", "view", "download-pdf", "download-spec",
221
+ "chatgpt", "claude", "perplexity", "grok", "aistudio", "devin", "windsurf",
222
+ "mcp", "add-mcp", "cursor", "vscode", "devin-mcp"
223
+ ]
224
+ },
225
+ {
226
+ "type": "object",
227
+ "additionalProperties": false,
228
+ "required": ["title", "href"],
229
+ "properties": {
230
+ "title": { "type": "string", "description": "Menu item label." },
231
+ "description": { "type": "string", "description": "Secondary line under the label." },
232
+ "href": { "type": "string", "description": "URL to open." },
233
+ "icon": { "type": "string", "description": "Lucide icon id." }
234
+ }
235
+ }
236
+ ]
237
+ }
238
+ },
239
+ "display": {
240
+ "type": "string",
241
+ "enum": ["header", "toc"],
242
+ "description": "Where the menu renders. Default \"header\"."
243
+ }
244
+ }
209
245
  }
210
246
  },
211
247
  "$defs": {
@@ -243,6 +279,7 @@
243
279
  "expanded": { "type": "boolean", "description": "Start expanded." },
244
280
  "root": { "type": "string", "description": "Landing page path for the group." },
245
281
  "openapi": { "$ref": "#/$defs/openapiRef" },
282
+ "api": { "$ref": "#/$defs/api" },
246
283
  "pages": { "$ref": "#/$defs/pages" }
247
284
  }
248
285
  },
@@ -255,11 +292,49 @@
255
292
  "icon": { "type": "string" },
256
293
  "href": { "type": "string", "description": "External/override link instead of in-site pages." },
257
294
  "openapi": { "$ref": "#/$defs/openapiRef" },
295
+ "api": { "$ref": "#/$defs/api" },
258
296
  "anchors": { "type": "array", "items": { "$ref": "#/$defs/anchor" } },
259
297
  "groups": { "type": "array", "items": { "$ref": "#/$defs/group" } },
260
298
  "pages": { "$ref": "#/$defs/pages" }
261
299
  }
262
300
  },
301
+ "api": {
302
+ "type": "object",
303
+ "additionalProperties": false,
304
+ "description": "API reference behaviour for OpenAPI-generated pages. Set at the root, or on a tab/group with `openapi` to override it for just that section.",
305
+ "properties": {
306
+ "server": {
307
+ "type": "string",
308
+ "description": "Base URL override for the playground + code samples (otherwise the spec's first server is used)."
309
+ },
310
+ "playground": {
311
+ "type": "object",
312
+ "additionalProperties": false,
313
+ "properties": {
314
+ "display": {
315
+ "type": "string",
316
+ "enum": ["interactive", "simple", "none"],
317
+ "description": "Playground mode. \"none\" disables the proxy / live send."
318
+ },
319
+ "proxy": {
320
+ "type": "boolean",
321
+ "description": "Send Try-It requests through the dev proxy to avoid CORS (default true)."
322
+ }
323
+ }
324
+ },
325
+ "examples": {
326
+ "type": "object",
327
+ "additionalProperties": false,
328
+ "properties": {
329
+ "languages": {
330
+ "type": "array",
331
+ "items": { "type": "string", "enum": ["curl", "javascript", "python", "ruby"] },
332
+ "description": "Which request-snippet languages to generate."
333
+ }
334
+ }
335
+ }
336
+ }
337
+ },
263
338
  "openapiRef": {
264
339
  "description": "Path or URL to an OpenAPI spec (JSON/YAML), or an array of them. Auto-generates a page per operation, grouped by tag.",
265
340
  "oneOf": [
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { Routes, Route, useLocation, Link } from 'react-router-dom';
2
+ import { Routes, Route, useLocation, useNavigate, Link } from 'react-router-dom';
3
3
  import { MDXProvider } from '@mdx-js/react';
4
4
  // The project's pages + navigation, generated from velu.json by
5
5
  // vite-plugin-velu-site (see src/vite-plugin-velu-site.js). `pages` is
@@ -14,6 +14,7 @@ import {
14
14
  NavSelect,
15
15
  Toc,
16
16
  TocBar,
17
+ ContextMenu,
17
18
  Callout,
18
19
  Accordion,
19
20
  AccordionGroup,
@@ -38,6 +39,7 @@ import {
38
39
  defaultMdxComponents,
39
40
  resolveIcon,
40
41
  Search,
42
+ pagefindSearch,
41
43
  Logo,
42
44
  SocialLinks,
43
45
  Tree,
@@ -48,6 +50,7 @@ import {
48
50
  ApiField,
49
51
  ApiSidebar,
50
52
  ApiSamples,
53
+ NotFound,
51
54
  VeluMark,
52
55
  } from 'velu-ui';
53
56
  import { X, ChevronDown, ChevronUp } from 'lucide-react';
@@ -717,6 +720,7 @@ function DocsPage() {
717
720
  // SSR and client compute these from the same pathname + the same
718
721
  // shared `resolve()`, so the render is identical (hydration-safe).
719
722
  const location = useLocation();
723
+ const navigate = useNavigate();
720
724
  const pathname = normalizeUrl(location.pathname);
721
725
  const entry = pages[pathname];
722
726
  const nav = React.useMemo(
@@ -724,9 +728,28 @@ function DocsPage() {
724
728
  [pathname],
725
729
  );
726
730
 
731
+ // Search source: Pagefind for content/excerpts, but resolve each result's
732
+ // breadcrumb from the real NAVIGATION (a page's nav group can differ from its
733
+ // URL path — e.g. essentials/markdown.mdx lives under the "Writing Content"
734
+ // group). Page rows show the path to the page; anchor rows include the page.
735
+ const searchDocs = React.useCallback(async (q) => {
736
+ const results = await pagefindSearch(q);
737
+ if (!Array.isArray(results)) return results;
738
+ return results.map((r) => {
739
+ const res = resolve(r.href.split('#')[0], navigation, pages);
740
+ if (!res?.breadcrumb?.length) return r;
741
+ const labels = res.breadcrumb.map((c) => c.label);
742
+ const crumbs = r.kind === 'anchor' ? labels : labels.slice(0, -1);
743
+ return crumbs.length ? { ...r, breadcrumb: crumbs } : r;
744
+ });
745
+ }, []);
746
+
727
747
  const frontmatter = entry?.frontmatter ?? {};
728
748
  const PageComponent = entry?.Component ?? null;
729
749
  const pageToc = entry?.toc ?? [];
750
+ // No renderable page for this route → show the 404 page (a clean centered
751
+ // takeover: header + footer stay, the docs sidebar/TOC are hidden).
752
+ const isNotFound = !PageComponent;
730
753
 
731
754
  // Switcher option sets (only render a switcher when an axis has >1
732
755
  // option). Anchors are pinned sidebar links shown in the context zone.
@@ -763,6 +786,27 @@ function DocsPage() {
763
786
  />
764
787
  );
765
788
 
789
+ // Keep document.title / meta description / <html lang> in sync on client-side
790
+ // navigation. The HTML is SSR'd with a per-page (build) or generic (dev) title;
791
+ // without this the tab title would stay frozen on the first page as you
792
+ // navigate the SPA, and JS-executing crawlers would read the stale value.
793
+ React.useEffect(() => {
794
+ const t = frontmatter.title;
795
+ document.title = isNotFound
796
+ ? `Page not found - ${site.name}`
797
+ : t && t !== site.name
798
+ ? `${t} - ${site.name}`
799
+ : site.name;
800
+ let descTag = document.querySelector('meta[name="description"]');
801
+ if (!descTag) {
802
+ descTag = document.createElement('meta');
803
+ descTag.setAttribute('name', 'description');
804
+ document.head.appendChild(descTag);
805
+ }
806
+ descTag.setAttribute('content', frontmatter.description || '');
807
+ document.documentElement.lang = nav?.activeLanguageCode || 'en';
808
+ }, [pathname, isNotFound, frontmatter.title, frontmatter.description, nav?.activeLanguageCode]);
809
+
766
810
  // Frontmatter title needs an id so scroll-spy + click-to-scroll work
767
811
  // against it like any other heading.
768
812
  const pageId = React.useMemo(() => {
@@ -1059,6 +1103,7 @@ function DocsPage() {
1059
1103
  data-sidebar-open={sidebarOpen ? 'true' : 'false'}
1060
1104
  data-drawer-open={drawerOpen ? 'true' : 'false'}
1061
1105
  data-api={entry?.api ? 'true' : 'false'}
1106
+ data-not-found={isNotFound ? 'true' : undefined}
1062
1107
  >
1063
1108
  {/* Scrim — visible at mobile while the drawer OR the chatbot
1064
1109
  sheet is open. Sits between the article (z-0) and the
@@ -1089,7 +1134,12 @@ function DocsPage() {
1089
1134
  tabsTrailing={languageSwitcher || undefined}
1090
1135
  center={
1091
1136
  <Cluster space="var(--s-6)" align="center">
1092
- <Search style={{ inlineSize: '30ch' }} unavailable={IS_DEV_PREVIEW} />
1137
+ <Search
1138
+ style={{ inlineSize: '30ch' }}
1139
+ unavailable={IS_DEV_PREVIEW}
1140
+ search={IS_DEV_PREVIEW ? undefined : searchDocs}
1141
+ onSelect={(item) => item.href && navigate(item.href)}
1142
+ />
1093
1143
  {/* Ask AI talks to the deployed site's AI backend — hidden in
1094
1144
  the local dev preview where there's nothing to talk to. */}
1095
1145
  {!IS_DEV_PREVIEW && (
@@ -1249,6 +1299,11 @@ function DocsPage() {
1249
1299
  )}
1250
1300
  </Stack>
1251
1301
  )}
1302
+ {/* Hairline separating the top anchor links from the nav sections
1303
+ (faithful to the sidebar design's anchor↔sidebar divider). */}
1304
+ {anchors.length > 0 && (
1305
+ <div className="velu-docs-context-divider" aria-hidden="true" />
1306
+ )}
1252
1307
  {/* Only this region scrolls — the context zone above stays
1253
1308
  pinned. The up/down arrows overlay its top/bottom edges and
1254
1309
  appear (via the data-fade-* attrs the scroll handler sets)
@@ -1353,6 +1408,21 @@ function DocsPage() {
1353
1408
  and the right margin collapses to 0 while the chatbot is
1354
1409
  open via [data-chat-open="true"]). */}
1355
1410
  <div className="velu-docs-layout__center">
1411
+ {isNotFound ? (
1412
+ <main className="velu-404-main">
1413
+ <NotFound
1414
+ homeHref="/"
1415
+ linkComponent={RouterLink}
1416
+ onSearch={() =>
1417
+ window.dispatchEvent(
1418
+ new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, metaKey: true }),
1419
+ )
1420
+ }
1421
+ onAskAI={IS_DEV_PREVIEW ? undefined : () => askAI('')}
1422
+ />
1423
+ </main>
1424
+ ) : (
1425
+ <>
1356
1426
  {/* Narrow-layout TOC bar — always in DOM, only visible at
1357
1427
  < 1024px (toggled by @container in toc-bar.css). Shares
1358
1428
  its `items` + `activeId` + `onSelect` API with the
@@ -1395,7 +1465,25 @@ function DocsPage() {
1395
1465
  })}
1396
1466
  </span>
1397
1467
  </button>
1398
- <div className="velu-docs-layout__article">
1468
+ <div className="velu-docs-layout__article" data-pagefind-body="">
1469
+ {/* Per-page agent/IDE action bar: the section eyebrow + a
1470
+ "Copy Page" split-button whose dropdown is driven by the
1471
+ Mintlify-compatible `contextual` config. Renders nothing
1472
+ when there's neither an eyebrow nor any enabled options. */}
1473
+ <ContextMenu
1474
+ eyebrow={
1475
+ nav?.breadcrumb && nav.breadcrumb.length > 1
1476
+ ? nav.breadcrumb[nav.breadcrumb.length - 2].label
1477
+ : undefined
1478
+ }
1479
+ pageUrl={pathname}
1480
+ title={frontmatter.title}
1481
+ isApi={entry?.api === true}
1482
+ siteName={site.name}
1483
+ options={site.contextual?.options ?? []}
1484
+ onAssistant={IS_DEV_PREVIEW ? undefined : () => askAI('')}
1485
+ />
1486
+
1399
1487
  {/* Page hero from MDX frontmatter. `.velu-hero` rules
1400
1488
  (in base.css) keep the title and description tightly
1401
1489
  grouped and create a clear break before the prose body. */}
@@ -1494,6 +1582,8 @@ function DocsPage() {
1494
1582
  )}
1495
1583
  </div>
1496
1584
  </main>
1585
+ </>
1586
+ )}
1497
1587
  </div>
1498
1588
 
1499
1589
  {/* Full site footer — only when the config provides link columns. Spans
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "$schema": "https://veludocs.com/velu.schema.json",
3
3
  "name": "Starter",
4
+ "url": "https://veludocs.com",
4
5
  "colors": {
5
6
  "primary": "#dc143c"
6
7
  },
7
8
  "favicon": "/favicon.svg",
9
+ "contextual": {
10
+ "options": ["copy", "view", "chatgpt", "claude", "mcp", "cursor", "vscode"]
11
+ },
8
12
  "navigation": {
9
13
  "anchors": [
10
14
  { "anchor": "Documentation", "icon": "book-open", "href": "https://veludocs.com" },