@aravindc26/velu 0.11.6 → 0.11.9

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.
@@ -1,8 +1,9 @@
1
1
  import type { Metadata } from 'next';
2
2
  import type { ReactNode } from 'react';
3
- import { getAppearance, getSeoConfig, getSiteFavicon, getSiteName, getSiteOrigin, getSitePrimaryColor } from '@/lib/velu';
3
+ import { getAppearance, getBannerConfig, getSeoConfig, getSiteDescription, getSiteFavicon, getSiteName, getSiteOrigin, getSitePrimaryColor } from '@/lib/velu';
4
4
  import { Providers } from '@/components/providers';
5
5
  import { VeluAssistant } from '@/components/assistant';
6
+ import { VeluBanner } from '@/components/banner';
6
7
  import './global.css';
7
8
  import './search.css';
8
9
  import './assistant.css';
@@ -17,10 +18,12 @@ function toAbsoluteUrl(origin: string, value: string): string {
17
18
  }
18
19
 
19
20
  const siteName = getSiteName();
21
+ const siteDescription = getSiteDescription();
20
22
  const siteOrigin = getSiteOrigin();
21
23
  const seo = getSeoConfig();
22
24
  const favicon = getSiteFavicon();
23
25
  const primaryColor = getSitePrimaryColor();
26
+ const bannerConfig = getBannerConfig();
24
27
  const generatedDefaultSocialImage = '/og/index.svg';
25
28
  const defaultSocialImage = seo.metatags['og:image'] ?? seo.metatags['twitter:image'] ?? generatedDefaultSocialImage;
26
29
  const absoluteDefaultSocialImage = defaultSocialImage ? toAbsoluteUrl(siteOrigin, defaultSocialImage) : undefined;
@@ -31,6 +34,7 @@ export const metadata: Metadata = {
31
34
  default: siteName,
32
35
  template: `%s - ${siteName}`,
33
36
  },
37
+ ...(siteDescription ? { description: siteDescription } : {}),
34
38
  applicationName: siteName,
35
39
  generator: seo.metatags.generator || 'Mintlify',
36
40
  appleWebApp: {
@@ -39,12 +43,14 @@ export const metadata: Metadata = {
39
43
  openGraph: {
40
44
  type: 'website',
41
45
  siteName,
46
+ ...(siteDescription ? { description: siteDescription } : {}),
42
47
  ...(absoluteDefaultSocialImage
43
48
  ? { images: [{ url: absoluteDefaultSocialImage, width: 1200, height: 630 }] }
44
49
  : {}),
45
50
  },
46
51
  twitter: {
47
52
  card: 'summary_large_image',
53
+ ...(siteDescription ? { description: siteDescription } : {}),
48
54
  ...(absoluteDefaultSocialImage ? { images: [absoluteDefaultSocialImage] } : {}),
49
55
  },
50
56
  ...(favicon
@@ -76,6 +82,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
76
82
  </head>
77
83
  <body className="min-h-screen" suppressHydrationWarning>
78
84
  <Providers theme={theme}>
85
+ {bannerConfig && <VeluBanner content={bannerConfig.content} dismissible={bannerConfig.dismissible} />}
79
86
  {children}
80
87
  <VeluAssistant />
81
88
  </Providers>
@@ -92,6 +92,10 @@
92
92
  background: var(--color-fd-accent, #27272a);
93
93
  }
94
94
 
95
+ .fd-search-result.is-active {
96
+ background: var(--color-fd-accent, #27272a);
97
+ }
98
+
95
99
  .fd-search-result-title {
96
100
  display: block;
97
101
  font-weight: 500;
@@ -0,0 +1,80 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
4
+
5
+ function hashContent(content: string): string {
6
+ let hash = 0;
7
+ for (let i = 0; i < content.length; i++) {
8
+ hash = ((hash << 5) - hash + content.charCodeAt(i)) | 0;
9
+ }
10
+ return `velu-banner-${hash}`;
11
+ }
12
+
13
+ function parseMarkdownLinks(text: string): string {
14
+ return text.replace(
15
+ /\[([^\]]+)\]\(([^)]+)\)/g,
16
+ '<a href="$2">$1</a>',
17
+ );
18
+ }
19
+
20
+ interface VeluBannerProps {
21
+ content: string;
22
+ dismissible: boolean;
23
+ }
24
+
25
+ function setSizeVar(el: HTMLElement | null) {
26
+ const h = el ? el.offsetHeight : 0;
27
+ document.documentElement.style.setProperty('--velu-announcement-h', `${h}px`);
28
+ }
29
+
30
+ export function VeluBanner({ content, dismissible }: VeluBannerProps) {
31
+ const storageKey = useMemo(() => hashContent(content), [content]);
32
+ const [dismissed, setDismissed] = useState(true);
33
+ const ref = useRef<HTMLDivElement>(null);
34
+
35
+ useEffect(() => {
36
+ if (dismissible) {
37
+ const stored = localStorage.getItem(storageKey);
38
+ setDismissed(stored === '1');
39
+ } else {
40
+ setDismissed(false);
41
+ }
42
+ }, [dismissible, storageKey]);
43
+
44
+ const measuredRef = useCallback((node: HTMLDivElement | null) => {
45
+ (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
46
+ setSizeVar(node);
47
+ }, []);
48
+
49
+ useEffect(() => {
50
+ if (dismissed) {
51
+ setSizeVar(null);
52
+ }
53
+ }, [dismissed]);
54
+
55
+ if (dismissed) return null;
56
+
57
+ const html = parseMarkdownLinks(content);
58
+
59
+ return (
60
+ <div className="velu-announcement" role="banner" ref={measuredRef}>
61
+ <span
62
+ className="velu-announcement-content"
63
+ dangerouslySetInnerHTML={{ __html: html }}
64
+ />
65
+ {dismissible && (
66
+ <button
67
+ type="button"
68
+ className="velu-announcement-dismiss"
69
+ aria-label="Dismiss banner"
70
+ onClick={() => {
71
+ localStorage.setItem(storageKey, '1');
72
+ setDismissed(true);
73
+ }}
74
+ >
75
+ &#x2715;
76
+ </button>
77
+ )}
78
+ </div>
79
+ );
80
+ }
@@ -1,8 +1,59 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useRef, useEffect } from 'react';
4
+ import type { VeluContextualOption } from '@/lib/velu';
4
5
 
5
- export function CopyPageButton() {
6
+ interface CopyPageButtonProps {
7
+ options: VeluContextualOption[];
8
+ mcpUrl: string;
9
+ }
10
+
11
+ const ICON_MAP: Record<string, React.ReactNode> = {
12
+ copy: (
13
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
14
+ ),
15
+ view: (
16
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
17
+ ),
18
+ chatgpt: (
19
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/></svg>
20
+ ),
21
+ claude: (
22
+ <svg width="18" height="18" viewBox="0 0 200 200" style={{ overflow: 'visible' }} fill="currentColor"><path d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"/></svg>
23
+ ),
24
+ perplexity: (
25
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M22.3977 7.0896h-2.3106V.0676l-7.5094 6.3542V.1577h-1.1554v6.1966L4.4904 0v7.0896H1.6023v10.3976h2.8882V24l6.932-6.3591v6.2005h1.1554v-6.0469l6.9318 6.1807v-6.4879h2.8882V7.0896zm-3.4657-4.531v4.531h-5.355l5.355-4.531zm-13.2862.0676 4.8691 4.4634H5.6458V2.6262zM2.7576 16.332V8.245h7.8476l-6.1149 6.1147v1.9723H2.7576zm2.8882 5.0404v-3.8852h.0001v-2.6488l5.7763-5.7764v7.0111l-5.7764 5.2993zm12.7086.0248-5.7766-5.1509V9.0618l5.7766 5.7766v6.5588zm2.8882-5.0652h-1.733v-1.9723L13.3948 8.245h7.8478v8.087z"/></svg>
26
+ ),
27
+ grok: (
28
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M3 3l8.5 12.5L3 21h1.9l7.1-4.6L18.1 21H21l-8.5-12.5L21 3h-1.9l-7.1 4.6L5.9 3H3z"/></svg>
29
+ ),
30
+ mcp: (
31
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
32
+ ),
33
+ 'add-mcp': (
34
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
35
+ ),
36
+ cursor: (
37
+ <svg width="18" height="18" viewBox="0 0 466.73 532.09" fill="currentColor"><path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/></svg>
38
+ ),
39
+ vscode: (
40
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M17.583 2.286L9.23 9.98 4.553 6.196 2 7.403v9.193l2.553 1.208 4.677-3.784L17.583 21.714 22 19.6V4.4l-4.417-2.114zM4.553 13.7V10.3L6.8 12l-2.247 1.7zM17.583 17.4l-6.06-5.4 6.06-5.4v10.8z"/></svg>
41
+ ),
42
+ };
43
+
44
+ function getDefaultIcon() {
45
+ return (
46
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
47
+ );
48
+ }
49
+
50
+ const EXTERNAL_IDS = new Set(['chatgpt', 'claude', 'perplexity', 'grok', 'cursor', 'vscode']);
51
+
52
+ function buildPromptUrl(baseUrl: string, paramName: string, pageUrl: string): string {
53
+ return `${baseUrl}${paramName}=Read+from+${encodeURIComponent(pageUrl)}+so+I+can+ask+questions+about+it.`;
54
+ }
55
+
56
+ export function CopyPageButton({ options, mcpUrl }: CopyPageButtonProps) {
6
57
  const [label, setLabel] = useState('Copy page');
7
58
  const [dropdownOpen, setDropdownOpen] = useState(false);
8
59
  const containerRef = useRef<HTMLDivElement>(null);
@@ -17,13 +68,20 @@ export function CopyPageButton() {
17
68
  return () => document.removeEventListener('click', handleClick);
18
69
  }, []);
19
70
 
20
- function doCopy() {
21
- setLabel('Copying...');
71
+ if (options.length === 0) return null;
72
+
73
+ function getPageText(): string {
22
74
  const titleEl = document.querySelector('h1');
23
75
  const article = document.querySelector('[data-pagefind-body]') || document.querySelector('main');
24
76
  let text = '';
25
77
  if (titleEl) text = '# ' + titleEl.textContent + '\n\n';
26
78
  if (article) text += (article as HTMLElement).innerText;
79
+ return text;
80
+ }
81
+
82
+ function doCopy() {
83
+ setLabel('Copying...');
84
+ const text = getPageText();
27
85
  if (text) {
28
86
  navigator.clipboard.writeText(text).then(() => {
29
87
  setLabel('Copied!');
@@ -33,7 +91,106 @@ export function CopyPageButton() {
33
91
  setDropdownOpen(false);
34
92
  }
35
93
 
36
- const currentUrl = typeof window !== 'undefined' ? window.location.href : '';
94
+ function flashLabel(msg: string) {
95
+ setLabel(msg);
96
+ setTimeout(() => setLabel('Copy page'), 1500);
97
+ setDropdownOpen(false);
98
+ }
99
+
100
+ function handleBuiltinAction(id: string) {
101
+ const currentUrl = window.location.href;
102
+ const currentPath = window.location.pathname;
103
+
104
+ switch (id) {
105
+ case 'copy':
106
+ doCopy();
107
+ return;
108
+ case 'view': {
109
+ const text = getPageText();
110
+ if (text) {
111
+ const blob = new Blob([text], { type: 'text/plain' });
112
+ window.open(URL.createObjectURL(blob), '_blank');
113
+ }
114
+ setDropdownOpen(false);
115
+ return;
116
+ }
117
+ case 'chatgpt':
118
+ window.open(buildPromptUrl('https://chatgpt.com/?', 'prompt', currentUrl), '_blank');
119
+ setDropdownOpen(false);
120
+ return;
121
+ case 'claude':
122
+ window.open(buildPromptUrl('https://claude.ai/new?', 'q', currentUrl), '_blank');
123
+ setDropdownOpen(false);
124
+ return;
125
+ case 'perplexity':
126
+ window.open(buildPromptUrl('https://www.perplexity.ai/?', 'q', currentUrl), '_blank');
127
+ setDropdownOpen(false);
128
+ return;
129
+ case 'grok':
130
+ window.open(buildPromptUrl('https://grok.com/?', 'q', currentUrl), '_blank');
131
+ setDropdownOpen(false);
132
+ return;
133
+ case 'mcp':
134
+ navigator.clipboard.writeText(mcpUrl).then(() => flashLabel('Copied MCP URL!'));
135
+ return;
136
+ case 'add-mcp':
137
+ navigator.clipboard.writeText(`npx @anthropic-ai/claude-code --mcp-server-uri=${mcpUrl}`).then(() => flashLabel('Copied!'));
138
+ return;
139
+ case 'cursor':
140
+ window.open(`cursor://anysphere.cursor.mcp/install?url=${encodeURIComponent(mcpUrl)}`, '_self');
141
+ setDropdownOpen(false);
142
+ return;
143
+ case 'vscode':
144
+ window.open(`vscode://anthropic.claude-code/mcp/install?url=${encodeURIComponent(mcpUrl)}`, '_self');
145
+ setDropdownOpen(false);
146
+ return;
147
+ }
148
+ }
149
+
150
+ function resolveCustomHref(href: string): string {
151
+ const currentUrl = typeof window !== 'undefined' ? window.location.href : '';
152
+ const currentPath = typeof window !== 'undefined' ? window.location.pathname : '';
153
+ return href.replace(/\$page/g, encodeURIComponent(currentUrl)).replace(/\$path/g, encodeURIComponent(currentPath));
154
+ }
155
+
156
+ function renderOption(opt: typeof options[number]) {
157
+ const icon = ICON_MAP[opt.id] ?? getDefaultIcon();
158
+ const isExternal = EXTERNAL_IDS.has(opt.id) || opt.type === 'custom';
159
+ const arrow = isExternal ? <span className="velu-external-arrow">↗</span> : null;
160
+
161
+ if (opt.type === 'custom' && opt.href) {
162
+ return (
163
+ <a
164
+ key={opt.id}
165
+ className="velu-copy-option"
166
+ href={resolveCustomHref(opt.href)}
167
+ target="_blank"
168
+ rel="noopener noreferrer"
169
+ onClick={() => setDropdownOpen(false)}
170
+ >
171
+ <span className="velu-copy-option-icon">{icon}</span>
172
+ <div>
173
+ <div className="velu-copy-option-title">{opt.title} {arrow}</div>
174
+ {opt.description ? <div className="velu-copy-option-desc">{opt.description}</div> : null}
175
+ </div>
176
+ </a>
177
+ );
178
+ }
179
+
180
+ return (
181
+ <button
182
+ key={opt.id}
183
+ className="velu-copy-option"
184
+ onClick={() => handleBuiltinAction(opt.id)}
185
+ >
186
+ <span className="velu-copy-option-icon">{icon}</span>
187
+ <div>
188
+ <div className="velu-copy-option-title">{opt.title} {arrow}</div>
189
+ {opt.description ? <div className="velu-copy-option-desc">{opt.description}</div> : null}
190
+ </div>
191
+ </button>
192
+ );
193
+ }
37
194
 
38
195
  return (
39
196
  <div className="velu-copy-page-container" ref={containerRef}>
@@ -58,37 +215,7 @@ export function CopyPageButton() {
58
215
 
59
216
  {dropdownOpen && (
60
217
  <div className="velu-copy-dropdown" onClick={(e) => e.stopPropagation()}>
61
- <button className="velu-copy-option" onClick={doCopy}>
62
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
63
- <div>
64
- <div className="velu-copy-option-title">Copy page</div>
65
- <div className="velu-copy-option-desc">Copy page as Markdown for LLMs</div>
66
- </div>
67
- </button>
68
- <a
69
- className="velu-copy-option"
70
- href={`https://chatgpt.com/?prompt=Read+from+${encodeURIComponent(currentUrl)}+so+I+can+ask+questions+about+it.`}
71
- target="_blank"
72
- rel="noopener noreferrer"
73
- >
74
- <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/></svg>
75
- <div>
76
- <div className="velu-copy-option-title">Open in ChatGPT <span className="velu-external-arrow">↗</span></div>
77
- <div className="velu-copy-option-desc">Ask questions about this page</div>
78
- </div>
79
- </a>
80
- <a
81
- className="velu-copy-option"
82
- href={`https://claude.ai/new?q=Read+from+${encodeURIComponent(currentUrl)}+so+I+can+ask+questions+about+it.`}
83
- target="_blank"
84
- rel="noopener noreferrer"
85
- >
86
- <svg width="18" height="18" viewBox="0 0 200 200" style={{ overflow: 'visible' }} fill="currentColor"><path d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"/></svg>
87
- <div>
88
- <div className="velu-copy-option-title">Open in Claude <span className="velu-external-arrow">↗</span></div>
89
- <div className="velu-copy-option-desc">Ask questions about this page</div>
90
- </div>
91
- </a>
218
+ {options.map(renderOption)}
92
219
  </div>
93
220
  )}
94
221
  </div>
@@ -0,0 +1,142 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { usePathname } from 'next/navigation';
5
+ import type { VeluDropdownOption, VeluIconLibrary } from '@/lib/velu';
6
+ import { VeluIcon } from '@/components/icon';
7
+
8
+ function ChevronDownIcon() {
9
+ return (
10
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
11
+ <path d="m6 9 6 6 6-6" />
12
+ </svg>
13
+ );
14
+ }
15
+
16
+ function withTrailingSlashPath(path: string): string {
17
+ if (!path.startsWith('/')) return path;
18
+ if (path === '/' || path.endsWith('/')) return path;
19
+ return `${path}/`;
20
+ }
21
+
22
+ function normalizeLanguageSet(languages: string[]): Set<string> {
23
+ return new Set(languages.map((language) => language.trim().toLowerCase()).filter(Boolean));
24
+ }
25
+
26
+ function getContainerIndex(pathname: string, languages: string[]): number {
27
+ const segments = pathname.split('/').filter(Boolean);
28
+ if (segments.length === 0) return 0;
29
+ const languageSet = normalizeLanguageSet(languages);
30
+ const first = segments[0]?.toLowerCase();
31
+ if (first && languageSet.has(first)) return 1;
32
+ return 0;
33
+ }
34
+
35
+ function applyLocalePrefix(path: string, pathname: string, languages: string[]): string {
36
+ if (/^(https?:|mailto:|tel:|#)/i.test(path)) return path;
37
+ const segments = pathname.split('/').filter(Boolean);
38
+ if (segments.length === 0) return path;
39
+ const languageSet = normalizeLanguageSet(languages);
40
+ const locale = segments[0]?.toLowerCase();
41
+ if (!locale || !languageSet.has(locale)) return path;
42
+ const normalized = path.startsWith('/') ? path.slice(1) : path;
43
+ return withTrailingSlashPath(`/${locale}/${normalized}`);
44
+ }
45
+
46
+ export function DropdownSwitcher({
47
+ dropdowns,
48
+ iconLibrary,
49
+ languages,
50
+ }: {
51
+ dropdowns: VeluDropdownOption[];
52
+ iconLibrary: VeluIconLibrary;
53
+ languages: string[];
54
+ }) {
55
+ const pathname = usePathname();
56
+ const [open, setOpen] = useState(false);
57
+ const ref = useRef<HTMLDivElement>(null);
58
+ const canSwitch = dropdowns.length > 1;
59
+
60
+ const current = useMemo(() => {
61
+ const segments = pathname.split('/').filter(Boolean);
62
+ const index = getContainerIndex(pathname, languages);
63
+ const activeSlug = segments[index] ?? '';
64
+ return dropdowns.find((dropdown) => dropdown.slug === activeSlug) ?? dropdowns[0];
65
+ }, [pathname, dropdowns, languages]);
66
+
67
+ useEffect(() => {
68
+ function handleClick(event: MouseEvent) {
69
+ if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
70
+ }
71
+ document.addEventListener('mousedown', handleClick);
72
+ return () => document.removeEventListener('mousedown', handleClick);
73
+ }, []);
74
+
75
+ if (!current || dropdowns.length === 0) return null;
76
+
77
+ function switchTo(target: VeluDropdownOption) {
78
+ setOpen(false);
79
+
80
+ const segments = pathname.split('/').filter(Boolean);
81
+ const index = getContainerIndex(pathname, languages);
82
+ const currentContainer = segments[index] ?? '';
83
+
84
+ if (current.slug === currentContainer) {
85
+ const nextSegments = [...segments];
86
+ nextSegments[index] = target.slug;
87
+ window.location.href = withTrailingSlashPath(`/${nextSegments.join('/')}`);
88
+ return;
89
+ }
90
+
91
+ window.location.href = applyLocalePrefix(target.defaultPath, pathname, languages);
92
+ }
93
+
94
+ return (
95
+ <div className="velu-product-switcher-wrap" ref={ref}>
96
+ <button
97
+ type="button"
98
+ className="velu-product-switcher"
99
+ onClick={canSwitch ? () => setOpen((value) => !value) : undefined}
100
+ aria-expanded={canSwitch ? open : undefined}
101
+ aria-haspopup={canSwitch ? 'menu' : undefined}
102
+ >
103
+ <span className="velu-product-switcher-label-wrap">
104
+ <VeluIcon
105
+ name={current.icon}
106
+ iconType={current.iconType}
107
+ library={iconLibrary}
108
+ className="velu-product-icon"
109
+ />
110
+ <span className="velu-product-switcher-label">{current.dropdown}</span>
111
+ </span>
112
+ {canSwitch ? <ChevronDownIcon /> : null}
113
+ </button>
114
+ {canSwitch && open && (
115
+ <div className="velu-product-menu" role="menu" aria-label="Dropdown sections">
116
+ {dropdowns.map((dropdown) => (
117
+ <button
118
+ key={dropdown.slug}
119
+ type="button"
120
+ className={`velu-product-option ${dropdown.slug === current.slug ? 'active' : ''}`}
121
+ onClick={() => switchTo(dropdown)}
122
+ role="menuitem"
123
+ >
124
+ <span className="velu-product-option-name-wrap">
125
+ <VeluIcon
126
+ name={dropdown.icon}
127
+ iconType={dropdown.iconType}
128
+ library={iconLibrary}
129
+ className="velu-product-option-icon"
130
+ />
131
+ <span className="velu-product-option-name">{dropdown.dropdown}</span>
132
+ </span>
133
+ {dropdown.description && (
134
+ <span className="velu-product-option-desc">{dropdown.description}</span>
135
+ )}
136
+ </button>
137
+ ))}
138
+ </div>
139
+ )}
140
+ </div>
141
+ );
142
+ }
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+
3
+ import Link from 'fumadocs-core/link';
4
+ import { usePathname } from 'fumadocs-core/framework';
5
+
6
+ interface HeaderTabLinkProps {
7
+ text: string;
8
+ href: string;
9
+ urls: string[];
10
+ }
11
+
12
+ function normalizePath(value: string): string {
13
+ if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
14
+ return value;
15
+ }
16
+
17
+ function isActivePath(pathname: string, href: string, urls: string[]): boolean {
18
+ const normalizedPath = normalizePath(pathname);
19
+ for (const candidate of urls) {
20
+ if (normalizePath(candidate) === normalizedPath) return true;
21
+ }
22
+
23
+ const normalizedHref = normalizePath(href);
24
+ return normalizedPath === normalizedHref || normalizedPath.startsWith(`${normalizedHref}/`);
25
+ }
26
+
27
+ export function HeaderTabLink({ text, href, urls }: HeaderTabLinkProps) {
28
+ const pathname = usePathname();
29
+ const active = isActivePath(pathname, href, urls);
30
+
31
+ return (
32
+ <Link
33
+ href={href}
34
+ className={[
35
+ 'text-sm text-fd-muted-foreground transition-colors hover:text-fd-accent-foreground',
36
+ active ? 'text-fd-primary' : '',
37
+ ].join(' ')}
38
+ data-active={active}
39
+ >
40
+ {text}
41
+ </Link>
42
+ );
43
+ }