@aravindc26/velu 0.11.0 → 0.11.3
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.
- package/package.json +15 -6
- package/schema/velu.schema.json +1251 -115
- package/src/build.ts +1121 -304
- package/src/cli.ts +90 -26
- package/src/engine/_server.mjs +1684 -277
- package/src/engine/app/(docs)/[...slug]/layout.tsx +371 -0
- package/src/engine/app/(docs)/[...slug]/page.tsx +926 -0
- package/src/engine/app/api/proxy/route.ts +23 -0
- package/src/engine/app/copy-page.css +59 -1
- package/src/engine/app/global.css +3157 -3
- package/src/engine/app/layout.tsx +56 -1
- package/src/engine/app/llms-file/route.ts +87 -0
- package/src/engine/app/llms-full-file/route.ts +62 -0
- package/src/engine/app/md-file/[...slug]/route.ts +409 -0
- package/src/engine/app/page.tsx +45 -0
- package/src/engine/app/robots.txt/route.ts +63 -0
- package/src/engine/app/rss-file/[...slug]/route.ts +169 -0
- package/src/engine/app/sitemap.xml/route.ts +82 -0
- package/src/engine/components/assistant.tsx +16 -5
- package/src/engine/components/changelog-filters.tsx +114 -0
- package/src/engine/components/code-group.tsx +383 -0
- package/src/engine/components/color.tsx +118 -0
- package/src/engine/components/expandable.tsx +77 -0
- package/src/engine/components/icon.tsx +136 -0
- package/src/engine/components/image-zoom-fallback.tsx +147 -0
- package/src/engine/components/image.tsx +111 -0
- package/src/engine/components/manual-api-playground.tsx +154 -0
- package/src/engine/components/mermaid.tsx +142 -0
- package/src/engine/components/openapi-toc-sync.tsx +59 -0
- package/src/engine/components/openapi.tsx +1682 -0
- package/src/engine/components/page-feedback.tsx +153 -0
- package/src/engine/components/product-switcher.tsx +27 -3
- package/src/engine/components/prompt.tsx +90 -0
- package/src/engine/components/providers.tsx +1 -6
- package/src/engine/components/search.tsx +4 -0
- package/src/engine/components/sidebar-links.tsx +13 -15
- package/src/engine/components/synced-tabs.tsx +57 -0
- package/src/engine/components/toc-examples.tsx +110 -0
- package/src/engine/components/view.tsx +344 -0
- package/src/engine/generated/redirects.ts +3 -0
- package/src/engine/lib/changelog.ts +246 -0
- package/src/engine/lib/layout.shared.ts +30 -2
- package/src/engine/lib/llms.ts +444 -0
- package/src/engine/lib/navigation-normalize.mjs +481 -412
- package/src/engine/lib/navigation-normalize.ts +261 -54
- package/src/engine/lib/redirects.ts +194 -0
- package/src/engine/lib/source.ts +107 -4
- package/src/engine/lib/velu.ts +368 -2
- package/src/engine/mdx-components.tsx +648 -0
- package/src/engine/middleware.ts +66 -0
- package/src/engine/public/icons/cursor-dark.svg +12 -0
- package/src/engine/public/icons/cursor-light.svg +12 -0
- package/src/engine/source.config.ts +98 -1
- package/src/engine/src/components/PageTitle.astro +16 -5
- package/src/engine/src/lib/velu.ts +11 -3
- package/src/navigation-normalize.ts +252 -54
- package/src/themes.ts +6 -6
- package/src/validate.ts +119 -6
- package/src/engine/app/(docs)/[[...slug]]/layout.tsx +0 -87
- package/src/engine/app/(docs)/[[...slug]]/page.tsx +0 -146
|
@@ -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
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { usePathname } from 'next/navigation';
|
|
5
5
|
import type { VeluProductOption } from '@/lib/velu';
|
|
6
|
+
import type { VeluIconLibrary } from '@/lib/velu';
|
|
7
|
+
import { VeluIcon } from '@/components/icon';
|
|
6
8
|
|
|
7
9
|
function ChevronDownIcon() {
|
|
8
10
|
return (
|
|
@@ -12,7 +14,13 @@ function ChevronDownIcon() {
|
|
|
12
14
|
);
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
export function ProductSwitcher({
|
|
17
|
+
export function ProductSwitcher({
|
|
18
|
+
products,
|
|
19
|
+
iconLibrary,
|
|
20
|
+
}: {
|
|
21
|
+
products: VeluProductOption[];
|
|
22
|
+
iconLibrary: VeluIconLibrary;
|
|
23
|
+
}) {
|
|
16
24
|
const pathname = usePathname();
|
|
17
25
|
const [open, setOpen] = useState(false);
|
|
18
26
|
const ref = useRef<HTMLDivElement>(null);
|
|
@@ -53,7 +61,15 @@ export function ProductSwitcher({ products }: { products: VeluProductOption[] })
|
|
|
53
61
|
return (
|
|
54
62
|
<div className="velu-product-switcher-wrap" ref={ref}>
|
|
55
63
|
<button type="button" className="velu-product-switcher" onClick={() => setOpen((v) => !v)}>
|
|
56
|
-
<span className="velu-product-switcher-label">
|
|
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>
|
|
57
73
|
<ChevronDownIcon />
|
|
58
74
|
</button>
|
|
59
75
|
{open && (
|
|
@@ -65,7 +81,15 @@ export function ProductSwitcher({ products }: { products: VeluProductOption[] })
|
|
|
65
81
|
className={`velu-product-option ${product.slug === current.slug ? 'active' : ''}`}
|
|
66
82
|
onClick={() => switchTo(product)}
|
|
67
83
|
>
|
|
68
|
-
<span className="velu-product-option-name">
|
|
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>
|
|
69
93
|
{product.description && (
|
|
70
94
|
<span className="velu-product-option-desc">{product.description}</span>
|
|
71
95
|
)}
|
|
@@ -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
|
+
}
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import type { ReactNode } from 'react';
|
|
4
|
-
import dynamic from 'next/dynamic';
|
|
5
4
|
import { RootProvider } from 'fumadocs-ui/provider/next';
|
|
6
|
-
|
|
7
|
-
const PagefindSearch = dynamic(
|
|
8
|
-
() => import('@/components/search').then((m) => m.PagefindSearch),
|
|
9
|
-
{ ssr: false }
|
|
10
|
-
);
|
|
5
|
+
import { PagefindSearch } from '@/components/search';
|
|
11
6
|
|
|
12
7
|
interface ProvidersProps {
|
|
13
8
|
children: ReactNode;
|
|
@@ -95,6 +95,10 @@ export function PagefindSearch({
|
|
|
95
95
|
|
|
96
96
|
useEffect(() => {
|
|
97
97
|
async function loadPagefind() {
|
|
98
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
99
|
+
setAvailable(false);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
98
102
|
try {
|
|
99
103
|
// Bypass bundler resolution — pagefind.js only exists in the static output
|
|
100
104
|
const pf = await new Function('return import("/pagefind/pagefind.js")')();
|
|
@@ -1,28 +1,18 @@
|
|
|
1
|
-
import { getGlobalAnchors, getLanguages } from '@/lib/velu';
|
|
1
|
+
import { getGlobalAnchors, getIconLibrary, getLanguages } from '@/lib/velu';
|
|
2
2
|
import { ThemeToggle } from '@/components/theme-toggle';
|
|
3
3
|
import { LanguageSwitcher } from '@/components/lang-switcher';
|
|
4
|
+
import { VeluIcon } from '@/components/icon';
|
|
4
5
|
|
|
5
6
|
function ExternalLinkIcon() {
|
|
6
7
|
return (
|
|
7
|
-
<
|
|
8
|
-
className="velu-sidebar-link-icon"
|
|
9
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
10
|
-
viewBox="0 0 24 24"
|
|
11
|
-
fill="none"
|
|
12
|
-
stroke="currentColor"
|
|
13
|
-
strokeWidth={2}
|
|
14
|
-
strokeLinecap="round"
|
|
15
|
-
strokeLinejoin="round"
|
|
16
|
-
>
|
|
17
|
-
<path d="M7 17 17 7" />
|
|
18
|
-
<path d="M7 7h10v10" />
|
|
19
|
-
</svg>
|
|
8
|
+
<VeluIcon name="external-link" className="velu-sidebar-link-icon" fallback={false} />
|
|
20
9
|
);
|
|
21
10
|
}
|
|
22
11
|
|
|
23
12
|
export function SidebarLinks() {
|
|
24
13
|
const anchors = getGlobalAnchors();
|
|
25
14
|
const languages = getLanguages();
|
|
15
|
+
const iconLibrary = getIconLibrary();
|
|
26
16
|
|
|
27
17
|
return (
|
|
28
18
|
<div className="velu-sidebar-footer">
|
|
@@ -36,7 +26,15 @@ export function SidebarLinks() {
|
|
|
36
26
|
rel="noopener noreferrer"
|
|
37
27
|
className="velu-sidebar-link"
|
|
38
28
|
>
|
|
39
|
-
<span className="velu-sidebar-link-
|
|
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>
|
|
40
38
|
<ExternalLinkIcon />
|
|
41
39
|
</a>
|
|
42
40
|
))}
|
|
@@ -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,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
|
+
}
|