@aravindc26/velu 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/package.json +15 -6
  2. package/schema/velu.schema.json +1864 -30
  3. package/src/build.ts +1161 -180
  4. package/src/cli.ts +121 -16
  5. package/src/engine/_server.mjs +1708 -192
  6. package/src/engine/app/(docs)/[...slug]/layout.tsx +377 -0
  7. package/src/engine/app/(docs)/[...slug]/page.tsx +917 -0
  8. package/src/engine/app/(docs)/layout.tsx +1 -13
  9. package/src/engine/app/api/proxy/route.ts +23 -0
  10. package/src/engine/app/copy-page.css +59 -1
  11. package/src/engine/app/global.css +3487 -6
  12. package/src/engine/app/layout.tsx +59 -8
  13. package/src/engine/app/llms-file/route.ts +87 -0
  14. package/src/engine/app/llms-full-file/route.ts +62 -0
  15. package/src/engine/app/md-file/[...slug]/route.ts +409 -0
  16. package/src/engine/app/page.tsx +45 -0
  17. package/src/engine/app/robots.txt/route.ts +61 -0
  18. package/src/engine/app/rss-file/[...slug]/route.ts +176 -0
  19. package/src/engine/app/search.css +20 -0
  20. package/src/engine/app/sitemap.xml/route.ts +80 -0
  21. package/src/engine/components/assistant.tsx +16 -5
  22. package/src/engine/components/changelog-filters.tsx +114 -0
  23. package/src/engine/components/code-group.tsx +383 -0
  24. package/src/engine/components/color.tsx +118 -0
  25. package/src/engine/components/expandable.tsx +77 -0
  26. package/src/engine/components/icon.tsx +136 -0
  27. package/src/engine/components/image-zoom-fallback.tsx +147 -0
  28. package/src/engine/components/image.tsx +111 -0
  29. package/src/engine/components/lang-switcher.tsx +95 -0
  30. package/src/engine/components/manual-api-playground.tsx +154 -0
  31. package/src/engine/components/mermaid.tsx +142 -0
  32. package/src/engine/components/openapi-toc-sync.tsx +59 -0
  33. package/src/engine/components/openapi.tsx +1679 -0
  34. package/src/engine/components/page-feedback.tsx +153 -0
  35. package/src/engine/components/product-switcher.tsx +102 -0
  36. package/src/engine/components/prompt.tsx +90 -0
  37. package/src/engine/components/providers.tsx +21 -0
  38. package/src/engine/components/search.tsx +70 -3
  39. package/src/engine/components/sidebar-links.tsx +49 -0
  40. package/src/engine/components/synced-tabs.tsx +57 -0
  41. package/src/engine/components/theme-toggle.tsx +39 -0
  42. package/src/engine/components/toc-examples.tsx +110 -0
  43. package/src/engine/components/version-switcher.tsx +89 -0
  44. package/src/engine/components/view.tsx +344 -0
  45. package/src/engine/generated/redirects.ts +3 -0
  46. package/src/engine/lib/changelog.ts +246 -0
  47. package/src/engine/lib/layout.shared.ts +57 -7
  48. package/src/engine/lib/llms.ts +444 -0
  49. package/src/engine/lib/navigation-normalize.mjs +525 -0
  50. package/src/engine/lib/navigation-normalize.ts +695 -0
  51. package/src/engine/lib/redirects.ts +194 -0
  52. package/src/engine/lib/source.ts +121 -4
  53. package/src/engine/lib/velu.ts +635 -5
  54. package/src/engine/mdx-components.tsx +648 -0
  55. package/src/engine/middleware.ts +66 -0
  56. package/src/engine/next.config.mjs +2 -2
  57. package/src/engine/public/icons/cursor-dark.svg +12 -0
  58. package/src/engine/public/icons/cursor-light.svg +12 -0
  59. package/src/engine/source.config.ts +98 -1
  60. package/src/engine/src/components/PageTitle.astro +16 -5
  61. package/src/engine/src/lib/velu.ts +97 -16
  62. package/src/navigation-normalize.ts +686 -0
  63. package/src/themes.ts +6 -6
  64. package/src/validate.ts +235 -24
  65. package/src/engine/app/(docs)/[[...slug]]/page.tsx +0 -69
@@ -0,0 +1,153 @@
1
+ 'use client';
2
+
3
+ import { useState, type MouseEvent as ReactMouseEvent } from 'react';
4
+ import { ThumbsDown, ThumbsUp } from 'lucide-react';
5
+
6
+ type Vote = 'yes' | 'no';
7
+
8
+ const NO_OPTIONS = [
9
+ 'Help me get started faster',
10
+ 'Make it easier to find what I\'m looking for',
11
+ 'Make it easy to understand the product and features',
12
+ 'Update this documentation',
13
+ 'Something else',
14
+ ];
15
+
16
+ export function PageFeedback() {
17
+ const [vote, setVote] = useState<Vote | null>(null);
18
+ const [selectedReason, setSelectedReason] = useState<string>('');
19
+ const [details, setDetails] = useState('');
20
+ const [email, setEmail] = useState('');
21
+
22
+ const showForm = vote === 'no';
23
+ const showOptionalInputs = selectedReason === 'Something else';
24
+
25
+ const onChooseVote = (value: Vote) => {
26
+ if (vote === value) return;
27
+ setVote(value);
28
+ setSelectedReason('');
29
+ setDetails('');
30
+ setEmail('');
31
+ };
32
+
33
+ const stopEvent = (event: ReactMouseEvent) => {
34
+ event.preventDefault();
35
+ event.stopPropagation();
36
+ };
37
+
38
+ const onCancel = () => {
39
+ setVote(null);
40
+ setSelectedReason('');
41
+ setDetails('');
42
+ setEmail('');
43
+ };
44
+
45
+ const onSubmit = () => {
46
+ // Placeholder for analytics/reporting integration.
47
+ onCancel();
48
+ };
49
+
50
+ return (
51
+ <div className="velu-page-feedback-block">
52
+ <div className="velu-page-feedback-row">
53
+ <p className="velu-page-feedback-question">Was this page helpful?</p>
54
+ <div className="velu-page-feedback-actions" role="group" aria-label="Feedback options">
55
+ <button
56
+ type="button"
57
+ className={['velu-page-feedback-btn', vote === 'yes' ? 'is-active' : ''].filter(Boolean).join(' ')}
58
+ aria-label="Mark page as helpful"
59
+ onClick={(event) => {
60
+ stopEvent(event);
61
+ onChooseVote('yes');
62
+ }}
63
+ >
64
+ <ThumbsUp />
65
+ <span>Yes</span>
66
+ </button>
67
+ <button
68
+ type="button"
69
+ className={['velu-page-feedback-btn', vote === 'no' ? 'is-active' : ''].filter(Boolean).join(' ')}
70
+ aria-label="Mark page as not helpful"
71
+ onClick={(event) => {
72
+ stopEvent(event);
73
+ onChooseVote('no');
74
+ }}
75
+ >
76
+ <ThumbsDown />
77
+ <span>No</span>
78
+ </button>
79
+ </div>
80
+ </div>
81
+
82
+ {showForm ? (
83
+ <div className="velu-page-feedback-panel">
84
+ <h3 className="velu-page-feedback-panel-title">How can we improve our product?</h3>
85
+
86
+ <div className="velu-page-feedback-options" role="radiogroup" aria-label="Feedback reasons">
87
+ {NO_OPTIONS.map((option) => {
88
+ const checked = selectedReason === option;
89
+ return (
90
+ <button
91
+ key={option}
92
+ type="button"
93
+ role="radio"
94
+ aria-checked={checked}
95
+ className={['velu-page-feedback-option', checked ? 'is-checked' : ''].filter(Boolean).join(' ')}
96
+ onClick={(event) => {
97
+ stopEvent(event);
98
+ setSelectedReason(option);
99
+ }}
100
+ >
101
+ <span className="velu-page-feedback-radio" aria-hidden="true" />
102
+ <span>{option}</span>
103
+ </button>
104
+ );
105
+ })}
106
+ </div>
107
+
108
+ {showOptionalInputs ? (
109
+ <div className="velu-page-feedback-inputs">
110
+ <textarea
111
+ className="velu-page-feedback-input"
112
+ rows={3}
113
+ placeholder="(Optional) Could you share more about your experience?"
114
+ value={details}
115
+ onChange={(event) => setDetails(event.target.value)}
116
+ />
117
+ <input
118
+ className="velu-page-feedback-input"
119
+ type="email"
120
+ placeholder="(Optional) Email"
121
+ value={email}
122
+ onChange={(event) => setEmail(event.target.value)}
123
+ />
124
+ </div>
125
+ ) : null}
126
+
127
+ <div className="velu-page-feedback-cta">
128
+ <button
129
+ type="button"
130
+ className="velu-page-feedback-cancel"
131
+ onClick={(event) => {
132
+ stopEvent(event);
133
+ onCancel();
134
+ }}
135
+ >
136
+ Cancel
137
+ </button>
138
+ <button
139
+ type="button"
140
+ className="velu-page-feedback-submit"
141
+ onClick={(event) => {
142
+ stopEvent(event);
143
+ onSubmit();
144
+ }}
145
+ >
146
+ Submit feedback
147
+ </button>
148
+ </div>
149
+ </div>
150
+ ) : null}
151
+ </div>
152
+ );
153
+ }
@@ -0,0 +1,102 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { usePathname } from 'next/navigation';
5
+ import type { VeluProductOption } from '@/lib/velu';
6
+ import type { VeluIconLibrary } from '@/lib/velu';
7
+ import { VeluIcon } from '@/components/icon';
8
+
9
+ function ChevronDownIcon() {
10
+ return (
11
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
12
+ <path d="m6 9 6 6 6-6" />
13
+ </svg>
14
+ );
15
+ }
16
+
17
+ export function ProductSwitcher({
18
+ products,
19
+ iconLibrary,
20
+ }: {
21
+ products: VeluProductOption[];
22
+ iconLibrary: VeluIconLibrary;
23
+ }) {
24
+ const pathname = usePathname();
25
+ const [open, setOpen] = useState(false);
26
+ const ref = useRef<HTMLDivElement>(null);
27
+
28
+ const current = useMemo(() => {
29
+ const firstSeg = pathname.split('/').filter(Boolean)[0] ?? '';
30
+ return products.find((p) => p.slug === firstSeg) ?? products[0];
31
+ }, [pathname, products]);
32
+
33
+ useEffect(() => {
34
+ function handleClick(e: MouseEvent) {
35
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
36
+ }
37
+ document.addEventListener('mousedown', handleClick);
38
+ return () => document.removeEventListener('mousedown', handleClick);
39
+ }, []);
40
+
41
+ if (!current || products.length <= 1) return null;
42
+
43
+ function switchTo(target: VeluProductOption) {
44
+ setOpen(false);
45
+
46
+ const segments = pathname.split('/').filter(Boolean);
47
+ const firstSeg = segments[0] ?? '';
48
+
49
+ if (current && current.slug === firstSeg) {
50
+ // Replace the product segment, keep tab/group/page segments
51
+ const rest = segments.slice(1);
52
+ if (rest.length > 0) {
53
+ window.location.href = '/' + [target.slug, ...rest].join('/');
54
+ return;
55
+ }
56
+ }
57
+
58
+ window.location.href = target.defaultPath;
59
+ }
60
+
61
+ return (
62
+ <div className="velu-product-switcher-wrap" ref={ref}>
63
+ <button type="button" className="velu-product-switcher" onClick={() => setOpen((v) => !v)}>
64
+ <span className="velu-product-switcher-label-wrap">
65
+ <VeluIcon
66
+ name={current.icon}
67
+ iconType={current.iconType}
68
+ library={iconLibrary}
69
+ className="velu-product-icon"
70
+ />
71
+ <span className="velu-product-switcher-label">{current.product}</span>
72
+ </span>
73
+ <ChevronDownIcon />
74
+ </button>
75
+ {open && (
76
+ <div className="velu-product-menu">
77
+ {products.map((product) => (
78
+ <button
79
+ key={product.slug}
80
+ type="button"
81
+ className={`velu-product-option ${product.slug === current.slug ? 'active' : ''}`}
82
+ onClick={() => switchTo(product)}
83
+ >
84
+ <span className="velu-product-option-name-wrap">
85
+ <VeluIcon
86
+ name={product.icon}
87
+ iconType={product.iconType}
88
+ library={iconLibrary}
89
+ className="velu-product-option-icon"
90
+ />
91
+ <span className="velu-product-option-name">{product.product}</span>
92
+ </span>
93
+ {product.description && (
94
+ <span className="velu-product-option-desc">{product.description}</span>
95
+ )}
96
+ </button>
97
+ ))}
98
+ </div>
99
+ )}
100
+ </div>
101
+ );
102
+ }
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ import { isValidElement, useMemo, useState, type ReactNode } from 'react';
4
+ import { VeluIcon } from '@/components/icon';
5
+
6
+ function flattenText(node: ReactNode): string {
7
+ if (node == null || typeof node === 'boolean') return '';
8
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
9
+ if (Array.isArray(node)) return node.map((item) => flattenText(item)).join('');
10
+ if (isValidElement(node)) return flattenText((node.props as { children?: ReactNode })?.children);
11
+ return '';
12
+ }
13
+
14
+ function renderInlineMarkdown(text: string): ReactNode[] {
15
+ const tokens = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*)/g).filter(Boolean);
16
+ return tokens.map((token, index) => {
17
+ if (token.startsWith('**') && token.endsWith('**')) {
18
+ return <strong key={`b-${index}`}>{token.slice(2, -2)}</strong>;
19
+ }
20
+ if (token.startsWith('*') && token.endsWith('*')) {
21
+ return <em key={`i-${index}`}>{token.slice(1, -1)}</em>;
22
+ }
23
+ return <span key={`t-${index}`}>{token}</span>;
24
+ });
25
+ }
26
+
27
+ export function VeluPrompt({
28
+ description,
29
+ children,
30
+ icon,
31
+ iconType,
32
+ actions,
33
+ className,
34
+ }: {
35
+ description?: string;
36
+ children?: ReactNode;
37
+ icon?: string;
38
+ iconType?: string;
39
+ actions?: string[];
40
+ className?: string;
41
+ }) {
42
+ const [copied, setCopied] = useState(false);
43
+ const promptText = useMemo(() => flattenText(children).trim(), [children]);
44
+ const label = (description && description.trim()) || 'Prompt';
45
+ const actionSet = new Set((Array.isArray(actions) && actions.length > 0 ? actions : ['copy']).map((item) => String(item).toLowerCase()));
46
+ const showCopy = actionSet.has('copy');
47
+ const showCursor = actionSet.has('cursor');
48
+
49
+ const onCopy = async () => {
50
+ if (!promptText) return;
51
+ try {
52
+ await navigator.clipboard.writeText(promptText);
53
+ setCopied(true);
54
+ window.setTimeout(() => setCopied(false), 1200);
55
+ } catch {
56
+ // no-op
57
+ }
58
+ };
59
+
60
+ const onCursor = () => {
61
+ if (!promptText) return;
62
+ const url = `https://cursor.com/link/prompt?text=${encodeURIComponent(promptText)}`;
63
+ window.open(url, '_blank', 'noopener,noreferrer');
64
+ };
65
+
66
+ return (
67
+ <div className={['velu-prompt', className].filter(Boolean).join(' ')}>
68
+ <div className="velu-prompt-row">
69
+ <div className="velu-prompt-left">
70
+ {icon ? <VeluIcon name={icon} iconType={iconType} className="velu-prompt-icon" /> : null}
71
+ <div className="velu-prompt-desc">{renderInlineMarkdown(label)}</div>
72
+ </div>
73
+ <div className="velu-prompt-actions">
74
+ {showCopy ? (
75
+ <button type="button" className="velu-prompt-copy" onClick={onCopy}>
76
+ {copied ? 'Copied' : 'Copy prompt'}
77
+ </button>
78
+ ) : null}
79
+ {showCursor ? (
80
+ <button type="button" className="velu-prompt-open" onClick={onCursor}>
81
+ <img src="/icons/cursor-dark.svg" alt="" aria-hidden="true" className="velu-prompt-open-icon velu-prompt-open-icon-on-light" />
82
+ <img src="/icons/cursor-light.svg" alt="" aria-hidden="true" className="velu-prompt-open-icon velu-prompt-open-icon-on-dark" />
83
+ Open in Cursor
84
+ </button>
85
+ ) : null}
86
+ </div>
87
+ </div>
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,21 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { RootProvider } from 'fumadocs-ui/provider/next';
5
+ import { PagefindSearch } from '@/components/search';
6
+
7
+ interface ProvidersProps {
8
+ children: ReactNode;
9
+ theme?: {
10
+ defaultTheme: string;
11
+ enableSystem: boolean;
12
+ };
13
+ }
14
+
15
+ export function Providers({ children, theme }: ProvidersProps) {
16
+ return (
17
+ <RootProvider theme={theme} search={{ SearchDialog: PagefindSearch }}>
18
+ {children}
19
+ </RootProvider>
20
+ );
21
+ }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useRef, useState, useCallback, type ReactNode } from 'react';
4
+ import { usePathname } from 'next/navigation';
4
5
 
5
6
  interface PagefindResult {
6
7
  url: string;
@@ -19,10 +20,54 @@ interface PagefindResponse {
19
20
 
20
21
  interface PagefindInstance {
21
22
  init: () => Promise<void>;
22
- search: (query: string) => Promise<PagefindResponse>;
23
+ search: (query: string, options?: { filters?: Record<string, string | string[]> }) => Promise<PagefindResponse>;
23
24
  destroy: () => void;
24
25
  }
25
26
 
27
+ interface SearchFilters {
28
+ language?: string;
29
+ version?: string;
30
+ product?: string;
31
+ }
32
+
33
+ // Extract language and version/product from URL path
34
+ // Format: /[lang]/[version|product]/... or /[version|product]/...
35
+ function extractFiltersFromPath(pathname: string): SearchFilters {
36
+ const filters: SearchFilters = {};
37
+ const segments = pathname.split('/').filter(Boolean);
38
+
39
+ // Common language codes (expand as needed)
40
+ const langCodes = new Set(['en', 'ja', 'es', 'fr', 'de', 'zh', 'ko', 'pt', 'ru', 'ar']);
41
+
42
+ if (segments.length === 0) return filters;
43
+
44
+ // Check if first segment is a language code
45
+ const firstSeg = segments[0];
46
+ if (langCodes.has(firstSeg)) {
47
+ filters.language = firstSeg;
48
+ // Look for version/product in second segment
49
+ if (segments.length > 1) {
50
+ const secondSeg = segments[1];
51
+ // Version patterns: v1, v2, v1.0, 1.0, etc.
52
+ if (/^v?\d/.test(secondSeg)) {
53
+ filters.version = secondSeg;
54
+ } else {
55
+ // Could be a product slug
56
+ filters.product = secondSeg;
57
+ }
58
+ }
59
+ } else {
60
+ // First segment might be version or product
61
+ if (/^v?\d/.test(firstSeg)) {
62
+ filters.version = firstSeg;
63
+ } else {
64
+ filters.product = firstSeg;
65
+ }
66
+ }
67
+
68
+ return filters;
69
+ }
70
+
26
71
  export function PagefindSearch({
27
72
  open,
28
73
  onOpenChange,
@@ -33,13 +78,27 @@ export function PagefindSearch({
33
78
  const inputRef = useRef<HTMLInputElement>(null);
34
79
  const dialogRef = useRef<HTMLDialogElement>(null);
35
80
  const pagefindRef = useRef<PagefindInstance | null>(null);
81
+ const pathname = usePathname();
36
82
  const [query, setQuery] = useState('');
37
83
  const [results, setResults] = useState<PagefindResult[]>([]);
38
84
  const [loading, setLoading] = useState(false);
39
85
  const [available, setAvailable] = useState(true);
86
+ const [activeFilters, setActiveFilters] = useState<SearchFilters>({});
87
+
88
+ // Extract filters from current URL when search opens
89
+ useEffect(() => {
90
+ if (open && pathname) {
91
+ const filters = extractFiltersFromPath(pathname);
92
+ setActiveFilters(filters);
93
+ }
94
+ }, [open, pathname]);
40
95
 
41
96
  useEffect(() => {
42
97
  async function loadPagefind() {
98
+ if (process.env.NODE_ENV !== 'production') {
99
+ setAvailable(false);
100
+ return;
101
+ }
43
102
  try {
44
103
  // Bypass bundler resolution — pagefind.js only exists in the static output
45
104
  const pf = await new Function('return import("/pagefind/pagefind.js")')();
@@ -72,7 +131,15 @@ export function PagefindSearch({
72
131
  }
73
132
  setLoading(true);
74
133
  try {
75
- const response = await pagefindRef.current.search(q);
134
+ // Build filters for pagefind
135
+ const filters: Record<string, string> = {};
136
+ if (activeFilters.language) filters.language = activeFilters.language;
137
+ if (activeFilters.version) filters.version = activeFilters.version;
138
+ if (activeFilters.product) filters.product = activeFilters.product;
139
+
140
+ const response = await pagefindRef.current.search(q,
141
+ Object.keys(filters).length > 0 ? { filters } : undefined
142
+ );
76
143
  const items = await Promise.all(
77
144
  response.results.slice(0, 8).map((r) => r.data())
78
145
  );
@@ -82,7 +149,7 @@ export function PagefindSearch({
82
149
  }
83
150
  setLoading(false);
84
151
  },
85
- []
152
+ [activeFilters]
86
153
  );
87
154
 
88
155
  return (
@@ -0,0 +1,49 @@
1
+ import { getGlobalAnchors, getIconLibrary, getLanguages } from '@/lib/velu';
2
+ import { ThemeToggle } from '@/components/theme-toggle';
3
+ import { LanguageSwitcher } from '@/components/lang-switcher';
4
+ import { VeluIcon } from '@/components/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
+ const anchors = getGlobalAnchors();
14
+ const languages = getLanguages();
15
+ const iconLibrary = getIconLibrary();
16
+
17
+ return (
18
+ <div className="velu-sidebar-footer">
19
+ {anchors.length > 0 && (
20
+ <div className="velu-sidebar-links">
21
+ {anchors.map((anchor) => (
22
+ <a
23
+ key={anchor.href}
24
+ href={anchor.href}
25
+ target="_blank"
26
+ rel="noopener noreferrer"
27
+ className="velu-sidebar-link"
28
+ >
29
+ <span className="velu-sidebar-link-left">
30
+ <VeluIcon
31
+ name={anchor.icon}
32
+ iconType={anchor.iconType}
33
+ library={iconLibrary}
34
+ className="velu-sidebar-link-leading-icon"
35
+ />
36
+ <span className="velu-sidebar-link-text">{anchor.anchor}</span>
37
+ </span>
38
+ <ExternalLinkIcon />
39
+ </a>
40
+ ))}
41
+ </div>
42
+ )}
43
+ <div className="velu-sidebar-footer-row">
44
+ {languages.length > 1 && <LanguageSwitcher languages={languages} defaultLang={languages[0]} />}
45
+ <ThemeToggle />
46
+ </div>
47
+ </div>
48
+ );
49
+ }
@@ -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
+ }