@aravindc26/velu 0.12.7 → 0.12.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.
Files changed (74) hide show
  1. package/package.json +1 -1
  2. package/src/build.ts +13 -0
  3. package/src/cli.ts +51 -9
  4. package/src/engine/app/(docs)/[...slug]/layout.tsx +21 -537
  5. package/src/engine/app/_preview/[sessionId]/[...slug]/layout.tsx +96 -0
  6. package/src/engine/app/_preview/[sessionId]/[...slug]/page.tsx +298 -0
  7. package/src/engine/app/_preview/[sessionId]/layout.tsx +56 -0
  8. package/src/{preview-engine/app → engine/app/_preview}/[sessionId]/page.tsx +7 -3
  9. package/src/engine/app/_preview/api/sessions/[sessionId]/assets/[...path]/route.ts +51 -0
  10. package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/init/route.ts +2 -2
  11. package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/route.ts +3 -3
  12. package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/sync/route.ts +2 -2
  13. package/src/{preview-engine/app → engine/app/_preview}/layout.tsx +4 -1
  14. package/src/engine/app/global.css +0 -3623
  15. package/src/engine/app/layout.tsx +4 -3
  16. package/src/engine/components/sidebar-links.tsx +11 -5
  17. package/src/engine/lib/docs-layout.tsx +605 -0
  18. package/src/engine/lib/layout.shared.ts +7 -7
  19. package/src/engine/lib/preview-config.ts +129 -0
  20. package/src/{preview-engine/lib/content-generator.ts → engine/lib/preview-content.ts} +242 -42
  21. package/src/engine/lib/source.ts +80 -97
  22. package/src/engine/lib/velu.ts +79 -55
  23. package/src/engine/mdx-components.tsx +14 -650
  24. package/src/engine/source.config.ts +11 -89
  25. package/src/engine/tsconfig.json +1 -0
  26. package/src/engine-core/components/assistant.tsx +361 -0
  27. package/src/engine-core/components/banner.tsx +80 -0
  28. package/src/engine-core/components/changelog-filters.tsx +114 -0
  29. package/src/engine-core/components/code-group.tsx +383 -0
  30. package/src/engine-core/components/color.tsx +118 -0
  31. package/src/engine-core/components/copy-page.tsx +223 -0
  32. package/src/engine-core/components/dropdown-switcher.tsx +142 -0
  33. package/src/engine-core/components/expandable.tsx +77 -0
  34. package/src/engine-core/components/header-tab-link.tsx +43 -0
  35. package/src/engine-core/components/icon.tsx +136 -0
  36. package/src/engine-core/components/image-zoom-fallback.tsx +147 -0
  37. package/src/engine-core/components/image.tsx +111 -0
  38. package/src/engine-core/components/lang-switcher.tsx +101 -0
  39. package/src/engine-core/components/manual-api-playground.tsx +154 -0
  40. package/src/engine-core/components/mermaid.tsx +142 -0
  41. package/src/engine-core/components/openapi-toc-sync.tsx +59 -0
  42. package/src/engine-core/components/openapi.tsx +1682 -0
  43. package/src/engine-core/components/page-feedback-api.test.ts +83 -0
  44. package/src/engine-core/components/page-feedback-api.ts +89 -0
  45. package/src/engine-core/components/page-feedback.tsx +200 -0
  46. package/src/engine-core/components/product-switcher.tsx +107 -0
  47. package/src/engine-core/components/prompt.tsx +90 -0
  48. package/src/engine-core/components/providers.tsx +21 -0
  49. package/src/engine-core/components/search.tsx +318 -0
  50. package/src/engine-core/components/sidebar-links.tsx +54 -0
  51. package/src/engine-core/components/synced-tabs.tsx +57 -0
  52. package/src/engine-core/components/theme-toggle.tsx +39 -0
  53. package/src/engine-core/components/toc-examples.tsx +110 -0
  54. package/src/engine-core/components/version-switcher.tsx +95 -0
  55. package/src/engine-core/components/view.tsx +344 -0
  56. package/src/engine-core/css/assistant.css +326 -0
  57. package/src/engine-core/css/copy-page.css +206 -0
  58. package/src/engine-core/css/search.css +142 -0
  59. package/src/engine-core/css/shared.css +3628 -0
  60. package/src/engine-core/lib/remark-plugins.ts +102 -0
  61. package/src/engine-core/lib/source-plugins.ts +105 -0
  62. package/src/engine-core/mdx-components.tsx +654 -0
  63. package/src/engine-core/types.ts +49 -0
  64. package/src/preview-engine/app/[sessionId]/[...slug]/page.tsx +0 -41
  65. package/src/preview-engine/app/[sessionId]/layout.tsx +0 -23
  66. package/src/preview-engine/app/global.css +0 -3
  67. package/src/preview-engine/lib/session-config.ts +0 -86
  68. package/src/preview-engine/lib/source.ts +0 -60
  69. package/src/preview-engine/next.config.mjs +0 -20
  70. package/src/preview-engine/postcss.config.mjs +0 -8
  71. package/src/preview-engine/source.config.ts +0 -26
  72. package/src/preview-engine/tsconfig.json +0 -32
  73. /package/src/{preview-engine/app → engine/app/_preview}/page.tsx +0 -0
  74. /package/src/{preview-engine/lib/auth.ts → engine/lib/preview-auth.ts} +0 -0
@@ -0,0 +1,318 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState, useCallback, type KeyboardEvent } from 'react';
4
+
5
+ interface PagefindResult {
6
+ url: string;
7
+ excerpt: string;
8
+ meta: { title?: string };
9
+ sub_results?: Array<{
10
+ url: string;
11
+ title: string;
12
+ excerpt: string;
13
+ }>;
14
+ }
15
+
16
+ interface PagefindResponse {
17
+ results: Array<{ data: () => Promise<PagefindResult> }>;
18
+ }
19
+
20
+ interface PagefindInstance {
21
+ init: () => Promise<void>;
22
+ search: (query: string, options?: { filters?: Record<string, string | string[]> }) => Promise<PagefindResponse>;
23
+ destroy: () => void;
24
+ }
25
+
26
+ interface SearchFilters {
27
+ language?: string;
28
+ version?: string;
29
+ product?: string;
30
+ }
31
+
32
+ function parseFilterAttribute(value: string | null): SearchFilters {
33
+ const out: SearchFilters = {};
34
+ if (!value) return out;
35
+
36
+ for (const entry of value.split(',')) {
37
+ const part = entry.trim();
38
+ if (!part) continue;
39
+ const index = part.indexOf(':');
40
+ if (index <= 0) continue;
41
+ const key = part.slice(0, index).trim();
42
+ const val = part.slice(index + 1).trim();
43
+ if (!val) continue;
44
+ if (key === 'language') out.language = val;
45
+ if (key === 'version') out.version = val;
46
+ if (key === 'product') out.product = val;
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function getActiveFiltersFromPage(): SearchFilters {
52
+ if (typeof document === 'undefined') return {};
53
+ const node = document.querySelector('[data-pagefind-body]');
54
+ if (!node) return {};
55
+ return parseFilterAttribute(node.getAttribute('data-pagefind-filter'));
56
+ }
57
+
58
+ function detectSiteBasePath(): string {
59
+ if (typeof document === 'undefined' || typeof window === 'undefined') return '';
60
+ const scripts = Array.from(document.querySelectorAll('script[src]')) as HTMLScriptElement[];
61
+ for (const script of scripts) {
62
+ const src = script.getAttribute('src');
63
+ if (!src || !src.includes('/_next/static/')) continue;
64
+ try {
65
+ const parsed = new URL(src, window.location.origin);
66
+ const marker = '/_next/static/';
67
+ const idx = parsed.pathname.indexOf(marker);
68
+ if (idx >= 0) return parsed.pathname.slice(0, idx);
69
+ } catch {
70
+ // ignore invalid script URL and continue
71
+ }
72
+ }
73
+ return '';
74
+ }
75
+
76
+ async function loadPagefindRuntime(): Promise<PagefindInstance | null> {
77
+ const basePath = detectSiteBasePath().replace(/\/+$/, '');
78
+ const candidates = Array.from(
79
+ new Set([
80
+ basePath ? `${basePath}/pagefind/pagefind.js` : '',
81
+ '/pagefind/pagefind.js',
82
+ ].filter(Boolean)),
83
+ );
84
+
85
+ for (const path of candidates) {
86
+ try {
87
+ const moduleLoader = new Function('modulePath', 'return import(modulePath)');
88
+ const mod = await moduleLoader(path) as unknown as PagefindInstance;
89
+ await mod.init();
90
+ return mod;
91
+ } catch {
92
+ // try next candidate
93
+ }
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ export function PagefindSearch({
100
+ open,
101
+ onOpenChange,
102
+ }: {
103
+ open: boolean;
104
+ onOpenChange: (open: boolean) => void;
105
+ }) {
106
+ const inputRef = useRef<HTMLInputElement>(null);
107
+ const dialogRef = useRef<HTMLDialogElement>(null);
108
+ const pagefindRef = useRef<PagefindInstance | null>(null);
109
+ const resultRefs = useRef<Array<HTMLAnchorElement | null>>([]);
110
+ const [query, setQuery] = useState('');
111
+ const [results, setResults] = useState<PagefindResult[]>([]);
112
+ const [loading, setLoading] = useState(false);
113
+ const [available, setAvailable] = useState(true);
114
+ const [activeFilters, setActiveFilters] = useState<SearchFilters>({});
115
+ const [activeIndex, setActiveIndex] = useState<number>(-1);
116
+
117
+ // Extract page-level filters from rendered metadata when search opens.
118
+ useEffect(() => {
119
+ if (open) {
120
+ setActiveFilters(getActiveFiltersFromPage());
121
+ }
122
+ }, [open]);
123
+
124
+ useEffect(() => {
125
+ async function loadPagefind() {
126
+ if (process.env.NODE_ENV !== 'production') {
127
+ setAvailable(false);
128
+ return;
129
+ }
130
+ try {
131
+ pagefindRef.current = await loadPagefindRuntime();
132
+ if (!pagefindRef.current) setAvailable(false);
133
+ } catch {
134
+ setAvailable(false);
135
+ }
136
+ }
137
+ loadPagefind();
138
+ }, []);
139
+
140
+ useEffect(() => {
141
+ if (open) {
142
+ dialogRef.current?.showModal();
143
+ setTimeout(() => inputRef.current?.focus(), 50);
144
+ } else {
145
+ dialogRef.current?.close();
146
+ setQuery('');
147
+ setResults([]);
148
+ setActiveIndex(-1);
149
+ }
150
+ }, [open]);
151
+
152
+ useEffect(() => {
153
+ if (!results.length) {
154
+ setActiveIndex(-1);
155
+ return;
156
+ }
157
+ if (activeIndex >= results.length) {
158
+ setActiveIndex(results.length - 1);
159
+ }
160
+ }, [results, activeIndex]);
161
+
162
+ useEffect(() => {
163
+ if (activeIndex < 0) return;
164
+ const node = resultRefs.current[activeIndex];
165
+ node?.scrollIntoView({ block: 'nearest' });
166
+ }, [activeIndex]);
167
+
168
+ const search = useCallback(
169
+ async (q: string) => {
170
+ setQuery(q);
171
+ if (!q.trim() || !pagefindRef.current) {
172
+ setResults([]);
173
+ setActiveIndex(-1);
174
+ return;
175
+ }
176
+ setLoading(true);
177
+ try {
178
+ // Build filters for pagefind
179
+ const filters: Record<string, string> = {};
180
+ if (activeFilters.language) filters.language = activeFilters.language;
181
+ if (activeFilters.version) filters.version = activeFilters.version;
182
+ if (activeFilters.product) filters.product = activeFilters.product;
183
+
184
+ const withFilters = Object.keys(filters).length > 0;
185
+ const response = await pagefindRef.current.search(
186
+ q,
187
+ withFilters ? { filters } : undefined,
188
+ );
189
+
190
+ // If scoped filters return nothing, fall back to global results
191
+ // to avoid false "No results" from stale/missing filter metadata.
192
+ const fallbackResponse = withFilters && response.results.length === 0
193
+ ? await pagefindRef.current.search(q)
194
+ : response;
195
+
196
+ const items = await Promise.all(
197
+ fallbackResponse.results.slice(0, 8).map((r) => r.data()),
198
+ );
199
+ setResults(items);
200
+ setActiveIndex(items.length > 0 ? 0 : -1);
201
+ } catch {
202
+ setResults([]);
203
+ setActiveIndex(-1);
204
+ }
205
+ setLoading(false);
206
+ },
207
+ [activeFilters]
208
+ );
209
+
210
+ const onInputKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
211
+ if (!results.length) return;
212
+
213
+ if (event.key === 'ArrowDown') {
214
+ event.preventDefault();
215
+ setActiveIndex((prev) => (prev < 0 ? 0 : (prev + 1) % results.length));
216
+ return;
217
+ }
218
+
219
+ if (event.key === 'ArrowUp') {
220
+ event.preventDefault();
221
+ setActiveIndex((prev) => {
222
+ if (prev < 0) return results.length - 1;
223
+ return prev === 0 ? results.length - 1 : prev - 1;
224
+ });
225
+ return;
226
+ }
227
+
228
+ if (event.key === 'Enter' && activeIndex >= 0 && activeIndex < results.length) {
229
+ event.preventDefault();
230
+ const target = results[activeIndex];
231
+ onOpenChange(false);
232
+ window.location.href = target.url;
233
+ }
234
+ }, [activeIndex, onOpenChange, results]);
235
+
236
+ return (
237
+ <dialog
238
+ ref={dialogRef}
239
+ className="fd-search-dialog"
240
+ onClick={(e) => {
241
+ if (e.target === dialogRef.current) onOpenChange(false);
242
+ }}
243
+ onClose={() => onOpenChange(false)}
244
+ >
245
+ <div className="fd-search-content">
246
+ <div className="fd-search-input-wrap">
247
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
248
+ <input
249
+ ref={inputRef}
250
+ type="text"
251
+ placeholder="Search documentation..."
252
+ value={query}
253
+ onChange={(e) => search(e.target.value)}
254
+ onKeyDown={onInputKeyDown}
255
+ className="fd-search-input"
256
+ />
257
+ <kbd className="fd-search-kbd" onClick={() => onOpenChange(false)}>
258
+ Esc
259
+ </kbd>
260
+ </div>
261
+
262
+ <div className="fd-search-results">
263
+ {!available && (
264
+ <p className="fd-search-empty">
265
+ Search is available after building the site.
266
+ </p>
267
+ )}
268
+ {available && loading && (
269
+ <p className="fd-search-empty">Searching…</p>
270
+ )}
271
+ {available && !loading && query && results.length === 0 && (
272
+ <p className="fd-search-empty">No results found.</p>
273
+ )}
274
+ {results.map((r, i) => (
275
+ <a
276
+ key={i}
277
+ href={r.url}
278
+ ref={(node) => {
279
+ resultRefs.current[i] = node;
280
+ }}
281
+ className={['fd-search-result', activeIndex === i ? 'is-active' : ''].join(' ')}
282
+ aria-current={activeIndex === i ? 'true' : undefined}
283
+ onMouseEnter={() => setActiveIndex(i)}
284
+ onClick={() => onOpenChange(false)}
285
+ >
286
+ <span className="fd-search-result-title">
287
+ {r.meta?.title || r.url}
288
+ </span>
289
+ <span
290
+ className="fd-search-result-excerpt"
291
+ dangerouslySetInnerHTML={{ __html: r.excerpt }}
292
+ />
293
+ </a>
294
+ ))}
295
+ </div>
296
+ </div>
297
+ </dialog>
298
+ );
299
+ }
300
+
301
+ export function SearchToggle({
302
+ onClick,
303
+ }: {
304
+ onClick: () => void;
305
+ }) {
306
+ return (
307
+ <button
308
+ type="button"
309
+ className="fd-search-trigger"
310
+ onClick={onClick}
311
+ aria-label="Search"
312
+ >
313
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
314
+ <span>Search…</span>
315
+ <kbd>⌘K</kbd>
316
+ </button>
317
+ );
318
+ }
@@ -0,0 +1,54 @@
1
+ import type { VeluAnchor, VeluIconLibrary } from '@core/types';
2
+ import { ThemeToggle } from './theme-toggle';
3
+ import { LanguageSwitcher } from './lang-switcher';
4
+ import { VeluIcon } from './icon';
5
+
6
+ function ExternalLinkIcon() {
7
+ return (
8
+ <VeluIcon name="external-link" className="velu-sidebar-link-icon" fallback={false} />
9
+ );
10
+ }
11
+
12
+ export function SidebarLinks({
13
+ anchors,
14
+ languages,
15
+ iconLibrary,
16
+ }: {
17
+ anchors: VeluAnchor[];
18
+ languages: string[];
19
+ iconLibrary: VeluIconLibrary;
20
+ }) {
21
+
22
+ return (
23
+ <div className="velu-sidebar-footer">
24
+ {anchors.length > 0 && (
25
+ <div className="velu-sidebar-links">
26
+ {anchors.map((anchor) => (
27
+ <a
28
+ key={anchor.href}
29
+ href={anchor.href}
30
+ target="_blank"
31
+ rel="noopener noreferrer"
32
+ className="velu-sidebar-link"
33
+ >
34
+ <span className="velu-sidebar-link-left">
35
+ <VeluIcon
36
+ name={anchor.icon}
37
+ iconType={anchor.iconType}
38
+ library={iconLibrary}
39
+ className="velu-sidebar-link-leading-icon"
40
+ />
41
+ <span className="velu-sidebar-link-text">{anchor.anchor}</span>
42
+ </span>
43
+ <ExternalLinkIcon />
44
+ </a>
45
+ ))}
46
+ </div>
47
+ )}
48
+ <div className="velu-sidebar-footer-row">
49
+ {languages.length > 1 && <LanguageSwitcher languages={languages} defaultLang={languages[0]} />}
50
+ <ThemeToggle />
51
+ </div>
52
+ </div>
53
+ );
54
+ }
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ import { Children, isValidElement, type ReactElement, type ReactNode, useMemo } from "react";
4
+ import { Tabs as FumaTabs } from "fumadocs-ui/components/tabs";
5
+
6
+ const SYNCED_GROUP_ID = "velu-synced-tabs";
7
+
8
+ function findTitle(node: ReactNode): string | undefined {
9
+ if (Array.isArray(node)) {
10
+ for (const item of node) {
11
+ const found = findTitle(item);
12
+ if (found) return found;
13
+ }
14
+ return undefined;
15
+ }
16
+ if (!isValidElement(node)) return undefined;
17
+ const props = (node as ReactElement<Record<string, unknown>>).props;
18
+ const direct = props?.title ?? props?.["data-title"];
19
+ if (typeof direct === "string" && direct.trim()) return direct.trim();
20
+ return findTitle(props?.children as ReactNode);
21
+ }
22
+
23
+ interface VeluSyncedTabsProps {
24
+ items?: string[];
25
+ children?: ReactNode;
26
+ sync?: boolean;
27
+ borderBottom?: boolean;
28
+ className?: string;
29
+ groupId?: string;
30
+ [key: string]: unknown;
31
+ }
32
+
33
+ export function VeluSyncedTabs({ items, children, sync = true, borderBottom, className, groupId, ...props }: VeluSyncedTabsProps) {
34
+ const tabItems = useMemo(() => {
35
+ if (Array.isArray(items) && items.length > 0) return items;
36
+ const tabChildren = Children.toArray(children).filter((child) => isValidElement(child)) as ReactElement<any>[];
37
+ return tabChildren.map((child, idx) => findTitle(child) ?? `Tab ${idx + 1}`);
38
+ }, [children, items]);
39
+
40
+ return (
41
+ <FumaTabs
42
+ {...(props as any)}
43
+ items={tabItems}
44
+ groupId={sync ? (groupId ?? SYNCED_GROUP_ID) : undefined}
45
+ persist={sync}
46
+ className={[
47
+ "velu-tabs-plain !border-0 !bg-transparent !rounded-none !my-2",
48
+ className,
49
+ borderBottom ? "velu-tabs-border-bottom" : "",
50
+ ]
51
+ .filter(Boolean)
52
+ .join(" ")}
53
+ >
54
+ {children}
55
+ </FumaTabs>
56
+ );
57
+ }
@@ -0,0 +1,39 @@
1
+ 'use client';
2
+
3
+ import { useTheme } from 'next-themes';
4
+ import { useEffect, useState } from 'react';
5
+
6
+ export function ThemeToggle() {
7
+ const { setTheme, resolvedTheme } = useTheme();
8
+ const [mounted, setMounted] = useState(false);
9
+
10
+ useEffect(() => setMounted(true), []);
11
+
12
+ const isDark = mounted ? resolvedTheme === 'dark' : false;
13
+
14
+ return (
15
+ <div className="velu-theme-toggle" style={mounted ? undefined : { opacity: 0 }}>
16
+ <button
17
+ type="button"
18
+ className={`velu-theme-btn ${!isDark ? 'active' : ''}`}
19
+ onClick={() => setTheme('light')}
20
+ aria-label="Light mode"
21
+ >
22
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" width="16" height="16">
23
+ <circle cx="12" cy="12" r="4" />
24
+ <path d="M12 2v2" /><path d="M12 20v2" /><path d="m4.93 4.93 1.41 1.41" /><path d="m17.66 17.66 1.41 1.41" /><path d="M2 12h2" /><path d="M20 12h2" /><path d="m6.34 17.66-1.41 1.41" /><path d="m19.07 4.93-1.41 1.41" />
25
+ </svg>
26
+ </button>
27
+ <button
28
+ type="button"
29
+ className={`velu-theme-btn ${isDark ? 'active' : ''}`}
30
+ onClick={() => setTheme('dark')}
31
+ aria-label="Dark mode"
32
+ >
33
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" width="16" height="16">
34
+ <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
35
+ </svg>
36
+ </button>
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import { usePathname } from 'next/navigation';
4
+ import { useEffect } from 'react';
5
+
6
+ export function TocExamples() {
7
+ const pathname = usePathname();
8
+
9
+ useEffect(() => {
10
+ const page = document.getElementById('nd-page');
11
+ const toc = document.getElementById('nd-toc');
12
+ const layout = document.getElementById('nd-docs-layout');
13
+ if (!page || !toc) return;
14
+
15
+ let wrapper: HTMLDivElement | null = null;
16
+ let placeholders: Array<{ node: HTMLElement; marker: Comment }> = [];
17
+ const media = window.matchMedia('(min-width: 1024px)');
18
+
19
+ const isTocVisible = () => {
20
+ if (!media.matches) return false;
21
+ const styles = window.getComputedStyle(toc);
22
+ return styles.display !== 'none' && styles.visibility !== 'hidden';
23
+ };
24
+
25
+ const mountIntoToc = () => {
26
+ if (wrapper) return;
27
+ const examples = Array.from(
28
+ page.querySelectorAll<HTMLElement>('.velu-request-example, .velu-response-example, .velu-panel'),
29
+ );
30
+ if (examples.length === 0) return;
31
+
32
+ wrapper = document.createElement('div');
33
+ wrapper.className = 'velu-toc-examples';
34
+ placeholders = [];
35
+
36
+ for (const node of examples) {
37
+ const marker = document.createComment('velu-example-placeholder');
38
+ node.parentNode?.insertBefore(marker, node);
39
+ placeholders.push({ node, marker });
40
+ node.classList.add('velu-in-toc-example');
41
+ wrapper.appendChild(node);
42
+ }
43
+
44
+ toc.classList.add('velu-toc-replaced');
45
+ page.classList.add('velu-page-with-toc-examples');
46
+ if (layout) layout.style.setProperty('--fd-toc-width', '420px');
47
+ toc.appendChild(wrapper);
48
+ };
49
+
50
+ const restoreToPage = () => {
51
+ if (wrapper) {
52
+ for (const { node, marker } of placeholders) {
53
+ if (marker.parentNode) {
54
+ marker.parentNode.insertBefore(node, marker);
55
+ marker.parentNode.removeChild(marker);
56
+ }
57
+ node.classList.remove('velu-in-toc-example');
58
+ }
59
+ wrapper.remove();
60
+ wrapper = null;
61
+ placeholders = [];
62
+ }
63
+
64
+ // Defensive cleanup in case of interrupted transitions.
65
+ toc.querySelectorAll('.velu-toc-examples').forEach((node) => node.remove());
66
+ document.querySelectorAll('.velu-in-toc-example').forEach((node) => {
67
+ node.classList.remove('velu-in-toc-example');
68
+ });
69
+
70
+ toc.classList.remove('velu-toc-replaced');
71
+ page.classList.remove('velu-page-with-toc-examples');
72
+ if (layout) layout.style.removeProperty('--fd-toc-width');
73
+ };
74
+
75
+ const syncPlacement = () => {
76
+ if (isTocVisible()) {
77
+ mountIntoToc();
78
+ } else {
79
+ restoreToPage();
80
+ }
81
+ };
82
+
83
+ syncPlacement();
84
+ if (media.addEventListener) {
85
+ media.addEventListener('change', syncPlacement);
86
+ } else {
87
+ // Fallback for older MediaQueryList implementations.
88
+ (media as any).addListener(syncPlacement);
89
+ }
90
+ window.addEventListener('resize', syncPlacement);
91
+ window.addEventListener('orientationchange', syncPlacement);
92
+ document.addEventListener('visibilitychange', syncPlacement);
93
+ const intervalId = window.setInterval(syncPlacement, 600);
94
+
95
+ return () => {
96
+ if (media.removeEventListener) {
97
+ media.removeEventListener('change', syncPlacement);
98
+ } else {
99
+ (media as any).removeListener(syncPlacement);
100
+ }
101
+ window.removeEventListener('resize', syncPlacement);
102
+ window.removeEventListener('orientationchange', syncPlacement);
103
+ document.removeEventListener('visibilitychange', syncPlacement);
104
+ window.clearInterval(intervalId);
105
+ restoreToPage();
106
+ };
107
+ }, [pathname]);
108
+
109
+ return null;
110
+ }
@@ -0,0 +1,95 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { usePathname } from 'next/navigation';
5
+ import type { VeluVersionOption } from '@core/types';
6
+
7
+ function VersionIcon() {
8
+ return (
9
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
10
+ <path d="M4 6h16" />
11
+ <path d="M4 12h16" />
12
+ <path d="M4 18h16" />
13
+ </svg>
14
+ );
15
+ }
16
+
17
+ function ChevronDownIcon() {
18
+ return (
19
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
20
+ <path d="m6 9 6 6 6-6" />
21
+ </svg>
22
+ );
23
+ }
24
+
25
+ function withTrailingSlashPath(path: string): string {
26
+ if (!path.startsWith('/')) return path;
27
+ if (path === '/' || path.endsWith('/')) return path;
28
+ return `${path}/`;
29
+ }
30
+
31
+ export function VersionSwitcher({ versions }: { versions: VeluVersionOption[] }) {
32
+ const pathname = usePathname();
33
+ const [open, setOpen] = useState(false);
34
+ const ref = useRef<HTMLDivElement>(null);
35
+
36
+ const fallback = useMemo(() => versions.find((v) => v.isDefault) ?? versions[0], [versions]);
37
+
38
+ const current = useMemo(() => {
39
+ const firstSeg = pathname.split('/').filter(Boolean)[0] ?? '';
40
+ return versions.find((version) => version.tabSlugs.includes(firstSeg)) ?? fallback;
41
+ }, [pathname, versions, fallback]);
42
+
43
+ useEffect(() => {
44
+ function handleClick(e: MouseEvent) {
45
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
46
+ }
47
+ document.addEventListener('mousedown', handleClick);
48
+ return () => document.removeEventListener('mousedown', handleClick);
49
+ }, []);
50
+
51
+ if (!fallback || versions.length <= 1) return null;
52
+
53
+ function switchTo(target: VeluVersionOption) {
54
+ setOpen(false);
55
+
56
+ const segments = pathname.split('/').filter(Boolean);
57
+ const firstSeg = segments[0] ?? '';
58
+
59
+ if (current && current.tabSlugs.includes(firstSeg)) {
60
+ const index = current.tabSlugs.indexOf(firstSeg);
61
+ const targetTab = target.tabSlugs[index] ?? target.tabSlugs[0];
62
+ if (targetTab) {
63
+ const rest = segments.slice(1);
64
+ window.location.href = withTrailingSlashPath('/' + [targetTab, ...rest].join('/'));
65
+ return;
66
+ }
67
+ }
68
+
69
+ window.location.href = withTrailingSlashPath(target.defaultPath);
70
+ }
71
+
72
+ return (
73
+ <div className="velu-version-switcher-wrap" ref={ref}>
74
+ <button type="button" className="velu-version-switcher" onClick={() => setOpen((v) => !v)}>
75
+ <VersionIcon />
76
+ <span>{current.version}</span>
77
+ <ChevronDownIcon />
78
+ </button>
79
+ {open && (
80
+ <div className="velu-version-menu">
81
+ {versions.map((version) => (
82
+ <button
83
+ key={version.slug}
84
+ type="button"
85
+ className={`velu-version-option ${version.slug === current.slug ? 'active' : ''}`}
86
+ onClick={() => switchTo(version)}
87
+ >
88
+ {version.version}
89
+ </button>
90
+ ))}
91
+ </div>
92
+ )}
93
+ </div>
94
+ );
95
+ }