@veluai/velu 0.2.25 → 0.2.27

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