@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.
Files changed (109) hide show
  1. package/README.md +80 -80
  2. package/dist/cli.js +37 -37
  3. package/package.json +64 -64
  4. package/runtime/velu-ui/base.css +320 -320
  5. package/runtime/velu-ui/components/Accordion.jsx +64 -64
  6. package/runtime/velu-ui/components/ApiClient.jsx +207 -207
  7. package/runtime/velu-ui/components/ApiField.jsx +87 -87
  8. package/runtime/velu-ui/components/ApiPath.jsx +63 -63
  9. package/runtime/velu-ui/components/ApiReferencePage.jsx +384 -384
  10. package/runtime/velu-ui/components/ApiSamples.jsx +36 -36
  11. package/runtime/velu-ui/components/ApiSidebar.jsx +122 -122
  12. package/runtime/velu-ui/components/AskBar.jsx +71 -71
  13. package/runtime/velu-ui/components/Callout.jsx +114 -114
  14. package/runtime/velu-ui/components/Card.jsx +131 -131
  15. package/runtime/velu-ui/components/Chatbot.jsx +885 -885
  16. package/runtime/velu-ui/components/CodeBlock.jsx +375 -375
  17. package/runtime/velu-ui/components/Columns.jsx +56 -56
  18. package/runtime/velu-ui/components/ContextMenu.jsx +298 -273
  19. package/runtime/velu-ui/components/ErrorCard.jsx +138 -138
  20. package/runtime/velu-ui/components/Field.jsx +81 -81
  21. package/runtime/velu-ui/components/Image.jsx +163 -163
  22. package/runtime/velu-ui/components/Logo.jsx +31 -31
  23. package/runtime/velu-ui/components/MethodBadge.jsx +31 -31
  24. package/runtime/velu-ui/components/NavSelect.jsx +108 -108
  25. package/runtime/velu-ui/components/NotFound.jsx +63 -63
  26. package/runtime/velu-ui/components/PageFeedback.jsx +219 -219
  27. package/runtime/velu-ui/components/PageFooter.jsx +145 -145
  28. package/runtime/velu-ui/components/PageHeader.jsx +422 -422
  29. package/runtime/velu-ui/components/PageNav.jsx +77 -77
  30. package/runtime/velu-ui/components/PoweredBy.jsx +51 -51
  31. package/runtime/velu-ui/components/Prompt.jsx +115 -115
  32. package/runtime/velu-ui/components/Search.jsx +460 -460
  33. package/runtime/velu-ui/components/Sidebar.jsx +254 -254
  34. package/runtime/velu-ui/components/SocialLinks.jsx +90 -90
  35. package/runtime/velu-ui/components/Steps.jsx +65 -65
  36. package/runtime/velu-ui/components/ThemeToggle.jsx +48 -48
  37. package/runtime/velu-ui/components/Toc.jsx +537 -537
  38. package/runtime/velu-ui/components/TocBar.jsx +195 -195
  39. package/runtime/velu-ui/components/Tree.jsx +87 -87
  40. package/runtime/velu-ui/components/TryItBar.jsx +102 -102
  41. package/runtime/velu-ui/components/accordion.css +92 -92
  42. package/runtime/velu-ui/components/api-page.css +208 -208
  43. package/runtime/velu-ui/components/api.css +635 -635
  44. package/runtime/velu-ui/components/ask-bar.css +94 -94
  45. package/runtime/velu-ui/components/card.css +105 -105
  46. package/runtime/velu-ui/components/chatbot.css +622 -622
  47. package/runtime/velu-ui/components/code-block.css +263 -263
  48. package/runtime/velu-ui/components/context-menu.css +173 -173
  49. package/runtime/velu-ui/components/docs-layout.css +822 -822
  50. package/runtime/velu-ui/components/field.css +82 -82
  51. package/runtime/velu-ui/components/image.css +237 -237
  52. package/runtime/velu-ui/components/nav-select.css +157 -157
  53. package/runtime/velu-ui/components/not-found.css +94 -94
  54. package/runtime/velu-ui/components/page-feedback.css +241 -241
  55. package/runtime/velu-ui/components/page-footer.css +130 -130
  56. package/runtime/velu-ui/components/page-header.css +558 -558
  57. package/runtime/velu-ui/components/page-nav.css +50 -50
  58. package/runtime/velu-ui/components/powered-by.css +92 -92
  59. package/runtime/velu-ui/components/prompt.css +99 -99
  60. package/runtime/velu-ui/components/search.css +307 -307
  61. package/runtime/velu-ui/components/sidebar.css +205 -205
  62. package/runtime/velu-ui/components/steps.css +77 -77
  63. package/runtime/velu-ui/components/theme-toggle.css +102 -102
  64. package/runtime/velu-ui/components/toc-bar.css +234 -234
  65. package/runtime/velu-ui/components/tree.css +49 -49
  66. package/runtime/velu-ui/index.js +54 -54
  67. package/runtime/velu-ui/lib/api-send.js +92 -92
  68. package/runtime/velu-ui/lib/brand-icons.jsx +103 -103
  69. package/runtime/velu-ui/lib/component-schemas.js +100 -100
  70. package/runtime/velu-ui/lib/copyText.js +64 -64
  71. package/runtime/velu-ui/lib/docs-assistant.js +250 -250
  72. package/runtime/velu-ui/lib/lang-icons.jsx +147 -147
  73. package/runtime/velu-ui/lib/pagefind.js +113 -113
  74. package/runtime/velu-ui/lib/prism-langs.js +957 -957
  75. package/runtime/velu-ui/lib/prism-loader.js +74 -74
  76. package/runtime/velu-ui/lib/resolveIcon.jsx +29 -29
  77. package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -66
  78. package/runtime/velu-ui/mdx-components.jsx +105 -105
  79. package/runtime/velu-ui/primitives/Cluster.jsx +49 -49
  80. package/runtime/velu-ui/primitives/Stack.jsx +63 -63
  81. package/runtime/velu-ui/primitives/Switcher.jsx +57 -57
  82. package/runtime/velu-ui/primitives/stack.css +3 -3
  83. package/runtime/velu-ui/primitives/switcher.css +25 -25
  84. package/runtime/velu-ui/styles.css +46 -46
  85. package/runtime/velu-ui/tokens.css +4 -4
  86. package/schema/velu.schema.json +423 -423
  87. package/src/lib/extract-mdx-error.js +170 -170
  88. package/src/lib/issues.js +159 -159
  89. package/src/lib/known-components.js +34 -34
  90. package/src/navigation.js +443 -443
  91. package/src/runtime/App.jsx +1669 -1668
  92. package/src/runtime/ErrorBoundary.jsx +54 -54
  93. package/src/runtime/client-entry.jsx +22 -22
  94. package/src/runtime/server-entry.jsx +16 -16
  95. package/src/template.html +48 -48
  96. package/templates/starter/ai-tools/claude-code.mdx +26 -26
  97. package/templates/starter/ai-tools/cursor.mdx +17 -17
  98. package/templates/starter/api-reference/introduction.mdx +43 -43
  99. package/templates/starter/development.mdx +19 -19
  100. package/templates/starter/essentials/code.mdx +29 -29
  101. package/templates/starter/essentials/images.mdx +29 -29
  102. package/templates/starter/essentials/markdown.mdx +25 -25
  103. package/templates/starter/essentials/navigation.mdx +39 -39
  104. package/templates/starter/essentials/settings.mdx +30 -30
  105. package/templates/starter/favicon.svg +6 -6
  106. package/templates/starter/index.mdx +31 -31
  107. package/templates/starter/openapi.json +160 -160
  108. package/templates/starter/quickstart.mdx +31 -31
  109. package/templates/starter/velu.json +41 -41
@@ -1,273 +1,298 @@
1
- import React from 'react';
2
- import {
3
- Copy,
4
- Check,
5
- ChevronDown,
6
- ArrowUpRight,
7
- Sparkles,
8
- Terminal,
9
- Download,
10
- Bot,
11
- Wind,
12
- } from 'lucide-react';
13
- import Cluster from '../primitives/Cluster.jsx';
14
- import {
15
- MarkdownIcon,
16
- OpenAIIcon,
17
- ClaudeIcon,
18
- PerplexityIcon,
19
- CursorIcon,
20
- VscodeIcon,
21
- } from '../lib/brand-icons.jsx';
22
-
23
- /**
24
- * ContextMenu — the per-page agent/IDE action bar shown at the top of every
25
- * page: the section eyebrow on the left, and a "Copy Page" split-button with a
26
- * dropdown on the right. The dropdown items are driven by the Mintlify-
27
- * compatible `contextual.options` config.
28
- *
29
- * The primary button copies the page's Markdown (the `.md` twin). Items either
30
- * copy, open the Markdown, download the spec, or open the page in an AI tool /
31
- * IDE. All side effects happen in click handlers (SSR-safe — no window at
32
- * render).
33
- *
34
- * @param {{
35
- * eyebrow?: string, // section/group label
36
- * pageUrl: string, // page path, e.g. "/quickstart"
37
- * title?: string, // page title (for AI prompts / filenames)
38
- * isApi?: boolean, // API page → enable download-spec
39
- * siteName?: string, // for MCP deep-link labels
40
- * options?: Array<string|{title,description,href,icon}>,
41
- * onAssistant?: () => void, // 'assistant' option → in-site Ask AI
42
- * }} props
43
- */
44
-
45
- // The Markdown URL for a page path (`/` → `/index.md`).
46
- const mdUrlForPage = (url) => (url === '/' ? '/index.md' : `${url}.md`);
47
-
48
- // key { icon, label, desc, external?, apiOnly?, kind, url? }
49
- const REGISTRY = {
50
- copy: { icon: Copy, label: 'Copy page', desc: 'Copy page as Markdown for LLMs', kind: 'copy' },
51
- view: { icon: MarkdownIcon, label: 'View as Markdown', desc: 'Open the raw Markdown', kind: 'view', external: true },
52
- 'download-spec': { icon: Download, label: 'Download OpenAPI spec', desc: 'Save this endpoint as YAML', kind: 'spec', apiOnly: true },
53
- assistant: { icon: Sparkles, label: 'Ask AI', desc: 'Ask the docs assistant about this page', kind: 'assistant' },
54
- chatgpt: { icon: OpenAIIcon, label: 'Open in ChatGPT', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://chatgpt.com/?hints=search&q=${q}` },
55
- claude: { icon: ClaudeIcon, label: 'Open in Claude', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://claude.ai/new?q=${q}` },
56
- perplexity: { icon: PerplexityIcon, label: 'Open in Perplexity', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://www.perplexity.ai/search?q=${q}` },
57
- grok: { icon: Bot, label: 'Open in Grok', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://grok.com/?q=${q}` },
58
- aistudio: { icon: Sparkles, label: 'Open in AI Studio', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://aistudio.google.com/app/prompts/new_chat?prompt=${q}` },
59
- devin: { icon: Bot, label: 'Open in Devin', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://app.devin.ai/?prompt=${q}` },
60
- windsurf: { icon: Wind, label: 'Open in Windsurf', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://windsurf.com/?q=${q}` },
61
- mcp: { icon: Terminal, label: 'Copy MCP install command', desc: 'Copy npx command to install MCP server', kind: 'mcp' },
62
- 'add-mcp': { icon: Terminal, label: 'Add MCP server', desc: 'Copy command to add the MCP server', kind: 'mcp' },
63
- cursor: { icon: CursorIcon, label: 'Connect to Cursor', desc: 'Install MCP Server on Cursor', kind: 'cursor', external: true },
64
- vscode: { icon: VscodeIcon, label: 'Connect to VS Code', desc: 'Install MCP Server on VS Code', kind: 'vscode', external: true },
65
- 'devin-mcp': { icon: Terminal, label: 'Connect to Devin', desc: 'Install MCP Server on Devin', kind: 'cursor', external: true },
66
- };
67
-
68
- export default function ContextMenu({
69
- eyebrow,
70
- pageUrl,
71
- title = '',
72
- isApi = false,
73
- siteName = 'docs',
74
- options = [],
75
- onAssistant,
76
- }) {
77
- const [open, setOpen] = React.useState(false);
78
- const [copied, setCopied] = React.useState(false);
79
- const rootRef = React.useRef(null);
80
- const copiedTimer = React.useRef(null);
81
-
82
- React.useEffect(() => {
83
- if (!open) return;
84
- const onDoc = (e) => {
85
- if (!rootRef.current?.contains(e.target)) setOpen(false);
86
- };
87
- const onKey = (e) => {
88
- if (e.key === 'Escape') setOpen(false);
89
- };
90
- document.addEventListener('mousedown', onDoc);
91
- document.addEventListener('keydown', onKey);
92
- return () => {
93
- document.removeEventListener('mousedown', onDoc);
94
- document.removeEventListener('keydown', onKey);
95
- };
96
- }, [open]);
97
-
98
- React.useEffect(() => () => clearTimeout(copiedTimer.current), []);
99
-
100
- const flashCopied = () => {
101
- setCopied(true);
102
- clearTimeout(copiedTimer.current);
103
- copiedTimer.current = setTimeout(() => setCopied(false), 1600);
104
- };
105
-
106
- const mdUrl = mdUrlForPage(pageUrl);
107
-
108
- // Build the descriptor list from the configured options (skip API-only items
109
- // off API pages, and any unsupported keys).
110
- const items = [];
111
- for (const o of options) {
112
- if (typeof o === 'object' && o) {
113
- items.push({ icon: ArrowUpRight, label: o.title, desc: o.description, external: true, kind: 'custom', href: o.href });
114
- continue;
115
- }
116
- const def = REGISTRY[o];
117
- if (!def) continue; // e.g. download-pdf no static pipeline
118
- if (def.apiOnly && !isApi) continue;
119
- items.push({ ...def, key: o });
120
- }
121
-
122
- const copyText = async (text) => {
123
- try {
124
- await navigator.clipboard.writeText(text);
125
- flashCopied();
126
- } catch {
127
- /* clipboard blocked no-op */
128
- }
129
- };
130
-
131
- const copyPage = async () => {
132
- try {
133
- const md = await fetch(mdUrl).then((r) => r.text());
134
- await copyText(md);
135
- } catch {
136
- /* fetch failed — no-op */
137
- }
138
- };
139
-
140
- const run = async (it) => {
141
- setOpen(false);
142
- const origin = window.location.origin;
143
- const absMd = origin + mdUrl;
144
- switch (it.kind) {
145
- case 'copy':
146
- return copyPage();
147
- case 'view':
148
- return void window.open(mdUrl, '_blank', 'noopener');
149
- case 'spec': {
150
- try {
151
- const text = await fetch(mdUrl).then((r) => r.text());
152
- const blob = new Blob([text], { type: 'text/yaml' });
153
- const a = document.createElement('a');
154
- a.href = URL.createObjectURL(blob);
155
- a.download = `${(title || 'openapi').replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.yaml`;
156
- a.click();
157
- URL.revokeObjectURL(a.href);
158
- } catch {
159
- /* no-op */
160
- }
161
- return;
162
- }
163
- case 'assistant':
164
- return onAssistant?.();
165
- case 'ai': {
166
- const q = encodeURIComponent(`Read ${absMd} and answer my questions about this page.`);
167
- return void window.open(it.url(q), '_blank', 'noopener');
168
- }
169
- case 'mcp':
170
- // Stubbed: the Velu MCP server isn't hosted yet — this is the intended
171
- // command, wired to <origin>/mcp once it exists.
172
- return copyText(`npx -y @veluai/mcp@latest ${origin}/mcp`);
173
- case 'cursor': {
174
- const cfg =
175
- typeof btoa === 'function' ? btoa(JSON.stringify({ url: `${origin}/mcp` })) : '';
176
- return void window.open(
177
- `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(siteName)}&config=${cfg}`,
178
- '_blank',
179
- 'noopener',
180
- );
181
- }
182
- case 'vscode': {
183
- const cfg = encodeURIComponent(JSON.stringify({ name: siteName, url: `${origin}/mcp` }));
184
- return void window.open(
185
- `https://insiders.vscode.dev/redirect/mcp/install?${cfg}`,
186
- '_blank',
187
- 'noopener',
188
- );
189
- }
190
- case 'custom':
191
- return void window.open(it.href, '_blank', 'noopener');
192
- default:
193
- return undefined;
194
- }
195
- };
196
-
197
- // Nothing to show → render nothing (keeps the page top clean).
198
- if (!eyebrow && !items.length) return null;
199
-
200
- return (
201
- <Cluster
202
- space="var(--s-2)"
203
- justify="space-between"
204
- align="flex-end"
205
- className="velu-context-bar"
206
- data-pagefind-ignore=""
207
- >
208
- {eyebrow ? <span className="velu-context-bar__eyebrow">{eyebrow}</span> : <span />}
209
-
210
- {items.length > 0 && (
211
- <div ref={rootRef} className="velu-context-menu" data-open={open ? 'true' : 'false'}>
212
- <div className="velu-context-menu__split">
213
- <button
214
- type="button"
215
- className="velu-context-menu__copy"
216
- onClick={copyPage}
217
- aria-label="Copy page as Markdown"
218
- >
219
- <span className="velu-context-menu__copy-icon" aria-hidden="true">
220
- {copied ? <Check size="1em" /> : <Copy size="1em" />}
221
- </span>
222
- <span>{copied ? 'Copied' : 'Copy Page'}</span>
223
- </button>
224
- <button
225
- type="button"
226
- className="velu-context-menu__toggle"
227
- aria-haspopup="menu"
228
- aria-expanded={open}
229
- aria-label="More actions"
230
- onClick={() => setOpen((v) => !v)}
231
- >
232
- <ChevronDown size="1em" aria-hidden="true" focusable="false" />
233
- </button>
234
- </div>
235
-
236
- <ul className="velu-context-menu__menu" role="menu" aria-hidden={!open}>
237
- {items.map((it, i) => {
238
- const Icon = it.icon;
239
- return (
240
- <li key={it.key ?? it.href ?? i} role="none">
241
- <button
242
- type="button"
243
- role="menuitem"
244
- className="velu-context-menu__item"
245
- tabIndex={open ? 0 : -1}
246
- onClick={() => run(it)}
247
- >
248
- <span className="velu-context-menu__item-icon" aria-hidden="true">
249
- <Icon size="1.1em" />
250
- </span>
251
- <span className="velu-context-menu__item-text">
252
- <span className="velu-context-menu__item-title">
253
- {it.label}
254
- {it.external && (
255
- <ArrowUpRight
256
- className="velu-context-menu__item-ext"
257
- size="0.85em"
258
- aria-hidden="true"
259
- />
260
- )}
261
- </span>
262
- {it.desc && <span className="velu-context-menu__item-desc">{it.desc}</span>}
263
- </span>
264
- </button>
265
- </li>
266
- );
267
- })}
268
- </ul>
269
- </div>
270
- )}
271
- </Cluster>
272
- );
273
- }
1
+ import React from 'react';
2
+ import {
3
+ Copy,
4
+ Check,
5
+ ChevronDown,
6
+ ArrowUpRight,
7
+ Sparkles,
8
+ Terminal,
9
+ Download,
10
+ Bot,
11
+ Wind,
12
+ } from 'lucide-react';
13
+ import Cluster from '../primitives/Cluster.jsx';
14
+ import {
15
+ MarkdownIcon,
16
+ OpenAIIcon,
17
+ ClaudeIcon,
18
+ PerplexityIcon,
19
+ CursorIcon,
20
+ VscodeIcon,
21
+ } from '../lib/brand-icons.jsx';
22
+
23
+ /**
24
+ * ContextMenu — the per-page agent/IDE action bar shown at the top of every
25
+ * page: the section eyebrow on the left, and a "Copy Page" split-button with a
26
+ * dropdown on the right. The dropdown items are driven by the Mintlify-
27
+ * compatible `contextual.options` config.
28
+ *
29
+ * The primary button copies the page's Markdown (the `.md` twin). Items either
30
+ * copy, open the Markdown, download the spec, or open the page in an AI tool /
31
+ * IDE. All side effects happen in click handlers (SSR-safe — no window at
32
+ * render).
33
+ *
34
+ * @param {{
35
+ * eyebrow?: string, // section/group label
36
+ * pageUrl: string, // page path, e.g. "/quickstart"
37
+ * title?: string, // page title (for AI prompts / filenames)
38
+ * isApi?: boolean, // API page → enable download-spec
39
+ * siteName?: string, // for MCP deep-link labels
40
+ * options?: Array<string|{title,description,href,icon}>,
41
+ * onAssistant?: () => void, // 'assistant' option → in-site Ask AI
42
+ * getMarkdown?: () => string|Promise<string>, // override the .md-twin fetch
43
+ * // (instant preview: no .md twin exists —
44
+ * // the raw source comes from the store)
45
+ * }} props
46
+ */
47
+
48
+ // The Markdown URL for a page path (`/` `/index.md`).
49
+ const mdUrlForPage = (url) => (url === '/' ? '/index.md' : `${url}.md`);
50
+
51
+ // key → { icon, label, desc, external?, apiOnly?, kind, url? }
52
+ const REGISTRY = {
53
+ copy: { icon: Copy, label: 'Copy page', desc: 'Copy page as Markdown for LLMs', kind: 'copy' },
54
+ view: { icon: MarkdownIcon, label: 'View as Markdown', desc: 'Open the raw Markdown', kind: 'view', external: true },
55
+ 'download-spec': { icon: Download, label: 'Download OpenAPI spec', desc: 'Save this endpoint as YAML', kind: 'spec', apiOnly: true },
56
+ assistant: { icon: Sparkles, label: 'Ask AI', desc: 'Ask the docs assistant about this page', kind: 'assistant' },
57
+ chatgpt: { icon: OpenAIIcon, label: 'Open in ChatGPT', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://chatgpt.com/?hints=search&q=${q}` },
58
+ claude: { icon: ClaudeIcon, label: 'Open in Claude', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://claude.ai/new?q=${q}` },
59
+ perplexity: { icon: PerplexityIcon, label: 'Open in Perplexity', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://www.perplexity.ai/search?q=${q}` },
60
+ grok: { icon: Bot, label: 'Open in Grok', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://grok.com/?q=${q}` },
61
+ aistudio: { icon: Sparkles, label: 'Open in AI Studio', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://aistudio.google.com/app/prompts/new_chat?prompt=${q}` },
62
+ devin: { icon: Bot, label: 'Open in Devin', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://app.devin.ai/?prompt=${q}` },
63
+ windsurf: { icon: Wind, label: 'Open in Windsurf', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://windsurf.com/?q=${q}` },
64
+ mcp: { icon: Terminal, label: 'Copy MCP install command', desc: 'Copy npx command to install MCP server', kind: 'mcp' },
65
+ 'add-mcp': { icon: Terminal, label: 'Add MCP server', desc: 'Copy command to add the MCP server', kind: 'mcp' },
66
+ cursor: { icon: CursorIcon, label: 'Connect to Cursor', desc: 'Install MCP Server on Cursor', kind: 'cursor', external: true },
67
+ vscode: { icon: VscodeIcon, label: 'Connect to VS Code', desc: 'Install MCP Server on VS Code', kind: 'vscode', external: true },
68
+ 'devin-mcp': { icon: Terminal, label: 'Connect to Devin', desc: 'Install MCP Server on Devin', kind: 'cursor', external: true },
69
+ };
70
+
71
+ export default function ContextMenu({
72
+ eyebrow,
73
+ pageUrl,
74
+ title = '',
75
+ isApi = false,
76
+ siteName = 'docs',
77
+ options = [],
78
+ onAssistant,
79
+ getMarkdown,
80
+ }) {
81
+ const [open, setOpen] = React.useState(false);
82
+ const [copied, setCopied] = React.useState(false);
83
+ const rootRef = React.useRef(null);
84
+ const copiedTimer = React.useRef(null);
85
+
86
+ React.useEffect(() => {
87
+ if (!open) return;
88
+ const onDoc = (e) => {
89
+ if (!rootRef.current?.contains(e.target)) setOpen(false);
90
+ };
91
+ const onKey = (e) => {
92
+ if (e.key === 'Escape') setOpen(false);
93
+ };
94
+ document.addEventListener('mousedown', onDoc);
95
+ document.addEventListener('keydown', onKey);
96
+ return () => {
97
+ document.removeEventListener('mousedown', onDoc);
98
+ document.removeEventListener('keydown', onKey);
99
+ };
100
+ }, [open]);
101
+
102
+ React.useEffect(() => () => clearTimeout(copiedTimer.current), []);
103
+
104
+ const flashCopied = () => {
105
+ setCopied(true);
106
+ clearTimeout(copiedTimer.current);
107
+ copiedTimer.current = setTimeout(() => setCopied(false), 1600);
108
+ };
109
+
110
+ const mdUrl = mdUrlForPage(pageUrl);
111
+
112
+ // Build the descriptor list from the configured options (skip API-only items
113
+ // off API pages, and any unsupported keys).
114
+ const items = [];
115
+ for (const o of options) {
116
+ if (typeof o === 'object' && o) {
117
+ items.push({ icon: ArrowUpRight, label: o.title, desc: o.description, external: true, kind: 'custom', href: o.href });
118
+ continue;
119
+ }
120
+ const def = REGISTRY[o];
121
+ if (!def) continue; // e.g. download-pdf — no static pipeline
122
+ if (def.apiOnly && !isApi) continue;
123
+ items.push({ ...def, key: o });
124
+ }
125
+
126
+ // Last-resort copy for contexts where the async Clipboard API is
127
+ // permission-deniednotably the instant preview's sandboxed iframe, whose
128
+ // opaque origin can never be granted clipboard-write.
129
+ const legacyCopy = (text) => {
130
+ const ta = document.createElement('textarea');
131
+ ta.value = text;
132
+ ta.setAttribute('readonly', '');
133
+ ta.style.position = 'fixed';
134
+ ta.style.opacity = '0';
135
+ document.body.appendChild(ta);
136
+ ta.select();
137
+ let ok = false;
138
+ try {
139
+ ok = document.execCommand('copy');
140
+ } catch {
141
+ /* not supported — give up below */
142
+ }
143
+ ta.remove();
144
+ return ok;
145
+ };
146
+
147
+ const copyText = async (text) => {
148
+ try {
149
+ await navigator.clipboard.writeText(text);
150
+ flashCopied();
151
+ } catch {
152
+ if (legacyCopy(text)) flashCopied();
153
+ }
154
+ };
155
+
156
+ const copyPage = async () => {
157
+ try {
158
+ const md = getMarkdown ? await getMarkdown() : await fetch(mdUrl).then((r) => r.text());
159
+ await copyText(md);
160
+ } catch {
161
+ /* fetch failed — no-op */
162
+ }
163
+ };
164
+
165
+ const run = async (it) => {
166
+ setOpen(false);
167
+ const origin = window.location.origin;
168
+ const absMd = origin + mdUrl;
169
+ switch (it.kind) {
170
+ case 'copy':
171
+ return copyPage();
172
+ case 'view':
173
+ return void window.open(mdUrl, '_blank', 'noopener');
174
+ case 'spec': {
175
+ try {
176
+ const text = await fetch(mdUrl).then((r) => r.text());
177
+ const blob = new Blob([text], { type: 'text/yaml' });
178
+ const a = document.createElement('a');
179
+ a.href = URL.createObjectURL(blob);
180
+ a.download = `${(title || 'openapi').replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.yaml`;
181
+ a.click();
182
+ URL.revokeObjectURL(a.href);
183
+ } catch {
184
+ /* no-op */
185
+ }
186
+ return;
187
+ }
188
+ case 'assistant':
189
+ return onAssistant?.();
190
+ case 'ai': {
191
+ const q = encodeURIComponent(`Read ${absMd} and answer my questions about this page.`);
192
+ return void window.open(it.url(q), '_blank', 'noopener');
193
+ }
194
+ case 'mcp':
195
+ // Stubbed: the Velu MCP server isn't hosted yet — this is the intended
196
+ // command, wired to <origin>/mcp once it exists.
197
+ return copyText(`npx -y @veluai/mcp@latest ${origin}/mcp`);
198
+ case 'cursor': {
199
+ const cfg =
200
+ typeof btoa === 'function' ? btoa(JSON.stringify({ url: `${origin}/mcp` })) : '';
201
+ return void window.open(
202
+ `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(siteName)}&config=${cfg}`,
203
+ '_blank',
204
+ 'noopener',
205
+ );
206
+ }
207
+ case 'vscode': {
208
+ const cfg = encodeURIComponent(JSON.stringify({ name: siteName, url: `${origin}/mcp` }));
209
+ return void window.open(
210
+ `https://insiders.vscode.dev/redirect/mcp/install?${cfg}`,
211
+ '_blank',
212
+ 'noopener',
213
+ );
214
+ }
215
+ case 'custom':
216
+ return void window.open(it.href, '_blank', 'noopener');
217
+ default:
218
+ return undefined;
219
+ }
220
+ };
221
+
222
+ // Nothing to show render nothing (keeps the page top clean).
223
+ if (!eyebrow && !items.length) return null;
224
+
225
+ return (
226
+ <Cluster
227
+ space="var(--s-2)"
228
+ justify="space-between"
229
+ align="flex-end"
230
+ className="velu-context-bar"
231
+ data-pagefind-ignore=""
232
+ >
233
+ {eyebrow ? <span className="velu-context-bar__eyebrow">{eyebrow}</span> : <span />}
234
+
235
+ {items.length > 0 && (
236
+ <div ref={rootRef} className="velu-context-menu" data-open={open ? 'true' : 'false'}>
237
+ <div className="velu-context-menu__split">
238
+ <button
239
+ type="button"
240
+ className="velu-context-menu__copy"
241
+ onClick={copyPage}
242
+ aria-label="Copy page as Markdown"
243
+ >
244
+ <span className="velu-context-menu__copy-icon" aria-hidden="true">
245
+ {copied ? <Check size="1em" /> : <Copy size="1em" />}
246
+ </span>
247
+ <span>{copied ? 'Copied' : 'Copy Page'}</span>
248
+ </button>
249
+ <button
250
+ type="button"
251
+ className="velu-context-menu__toggle"
252
+ aria-haspopup="menu"
253
+ aria-expanded={open}
254
+ aria-label="More actions"
255
+ onClick={() => setOpen((v) => !v)}
256
+ >
257
+ <ChevronDown size="1em" aria-hidden="true" focusable="false" />
258
+ </button>
259
+ </div>
260
+
261
+ <ul className="velu-context-menu__menu" role="menu" aria-hidden={!open}>
262
+ {items.map((it, i) => {
263
+ const Icon = it.icon;
264
+ return (
265
+ <li key={it.key ?? it.href ?? i} role="none">
266
+ <button
267
+ type="button"
268
+ role="menuitem"
269
+ className="velu-context-menu__item"
270
+ tabIndex={open ? 0 : -1}
271
+ onClick={() => run(it)}
272
+ >
273
+ <span className="velu-context-menu__item-icon" aria-hidden="true">
274
+ <Icon size="1.1em" />
275
+ </span>
276
+ <span className="velu-context-menu__item-text">
277
+ <span className="velu-context-menu__item-title">
278
+ {it.label}
279
+ {it.external && (
280
+ <ArrowUpRight
281
+ className="velu-context-menu__item-ext"
282
+ size="0.85em"
283
+ aria-hidden="true"
284
+ />
285
+ )}
286
+ </span>
287
+ {it.desc && <span className="velu-context-menu__item-desc">{it.desc}</span>}
288
+ </span>
289
+ </button>
290
+ </li>
291
+ );
292
+ })}
293
+ </ul>
294
+ </div>
295
+ )}
296
+ </Cluster>
297
+ );
298
+ }