@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.
- package/package.json +1 -1
- package/schema/velu.schema.json +383 -122
- package/src/build.ts +679 -551
- package/src/cli.ts +65 -2
- package/src/engine/app/(docs)/[...slug]/layout.tsx +155 -13
- package/src/engine/app/(docs)/[...slug]/page.tsx +77 -9
- package/src/engine/app/copy-page.css +17 -1
- package/src/engine/app/global.css +111 -5
- package/src/engine/app/layout.tsx +8 -1
- package/src/engine/app/search.css +4 -0
- package/src/engine/components/banner.tsx +80 -0
- package/src/engine/components/copy-page.tsx +162 -35
- package/src/engine/components/dropdown-switcher.tsx +142 -0
- package/src/engine/components/header-tab-link.tsx +43 -0
- package/src/engine/components/search.tsx +136 -49
- package/src/engine/lib/layout.shared.ts +68 -68
- package/src/engine/lib/velu.ts +297 -0
- package/src/validate.ts +8 -0
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, useState, useCallback, type
|
|
4
|
-
import { usePathname } from 'next/navigation';
|
|
3
|
+
import { useEffect, useRef, useState, useCallback, type KeyboardEvent } from 'react';
|
|
5
4
|
|
|
6
5
|
interface PagefindResult {
|
|
7
6
|
url: string;
|
|
@@ -30,42 +29,71 @@ interface SearchFilters {
|
|
|
30
29
|
product?: string;
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
58
71
|
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
65
93
|
}
|
|
66
94
|
}
|
|
67
|
-
|
|
68
|
-
return
|
|
95
|
+
|
|
96
|
+
return null;
|
|
69
97
|
}
|
|
70
98
|
|
|
71
99
|
export function PagefindSearch({
|
|
@@ -78,20 +106,20 @@ export function PagefindSearch({
|
|
|
78
106
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
79
107
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
80
108
|
const pagefindRef = useRef<PagefindInstance | null>(null);
|
|
81
|
-
const
|
|
109
|
+
const resultRefs = useRef<Array<HTMLAnchorElement | null>>([]);
|
|
82
110
|
const [query, setQuery] = useState('');
|
|
83
111
|
const [results, setResults] = useState<PagefindResult[]>([]);
|
|
84
112
|
const [loading, setLoading] = useState(false);
|
|
85
113
|
const [available, setAvailable] = useState(true);
|
|
86
114
|
const [activeFilters, setActiveFilters] = useState<SearchFilters>({});
|
|
115
|
+
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
|
87
116
|
|
|
88
|
-
// Extract filters from
|
|
117
|
+
// Extract page-level filters from rendered metadata when search opens.
|
|
89
118
|
useEffect(() => {
|
|
90
|
-
if (open
|
|
91
|
-
|
|
92
|
-
setActiveFilters(filters);
|
|
119
|
+
if (open) {
|
|
120
|
+
setActiveFilters(getActiveFiltersFromPage());
|
|
93
121
|
}
|
|
94
|
-
}, [open
|
|
122
|
+
}, [open]);
|
|
95
123
|
|
|
96
124
|
useEffect(() => {
|
|
97
125
|
async function loadPagefind() {
|
|
@@ -100,10 +128,8 @@ export function PagefindSearch({
|
|
|
100
128
|
return;
|
|
101
129
|
}
|
|
102
130
|
try {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
await pf.init();
|
|
106
|
-
pagefindRef.current = pf as unknown as PagefindInstance;
|
|
131
|
+
pagefindRef.current = await loadPagefindRuntime();
|
|
132
|
+
if (!pagefindRef.current) setAvailable(false);
|
|
107
133
|
} catch {
|
|
108
134
|
setAvailable(false);
|
|
109
135
|
}
|
|
@@ -119,14 +145,32 @@ export function PagefindSearch({
|
|
|
119
145
|
dialogRef.current?.close();
|
|
120
146
|
setQuery('');
|
|
121
147
|
setResults([]);
|
|
148
|
+
setActiveIndex(-1);
|
|
122
149
|
}
|
|
123
150
|
}, [open]);
|
|
124
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
|
+
|
|
125
168
|
const search = useCallback(
|
|
126
169
|
async (q: string) => {
|
|
127
170
|
setQuery(q);
|
|
128
171
|
if (!q.trim() || !pagefindRef.current) {
|
|
129
172
|
setResults([]);
|
|
173
|
+
setActiveIndex(-1);
|
|
130
174
|
return;
|
|
131
175
|
}
|
|
132
176
|
setLoading(true);
|
|
@@ -137,21 +181,58 @@ export function PagefindSearch({
|
|
|
137
181
|
if (activeFilters.version) filters.version = activeFilters.version;
|
|
138
182
|
if (activeFilters.product) filters.product = activeFilters.product;
|
|
139
183
|
|
|
140
|
-
const
|
|
141
|
-
|
|
184
|
+
const withFilters = Object.keys(filters).length > 0;
|
|
185
|
+
const response = await pagefindRef.current.search(
|
|
186
|
+
q,
|
|
187
|
+
withFilters ? { filters } : undefined,
|
|
142
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
|
+
|
|
143
196
|
const items = await Promise.all(
|
|
144
|
-
|
|
197
|
+
fallbackResponse.results.slice(0, 8).map((r) => r.data()),
|
|
145
198
|
);
|
|
146
199
|
setResults(items);
|
|
200
|
+
setActiveIndex(items.length > 0 ? 0 : -1);
|
|
147
201
|
} catch {
|
|
148
202
|
setResults([]);
|
|
203
|
+
setActiveIndex(-1);
|
|
149
204
|
}
|
|
150
205
|
setLoading(false);
|
|
151
206
|
},
|
|
152
207
|
[activeFilters]
|
|
153
208
|
);
|
|
154
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
|
+
|
|
155
236
|
return (
|
|
156
237
|
<dialog
|
|
157
238
|
ref={dialogRef}
|
|
@@ -170,6 +251,7 @@ export function PagefindSearch({
|
|
|
170
251
|
placeholder="Search documentation..."
|
|
171
252
|
value={query}
|
|
172
253
|
onChange={(e) => search(e.target.value)}
|
|
254
|
+
onKeyDown={onInputKeyDown}
|
|
173
255
|
className="fd-search-input"
|
|
174
256
|
/>
|
|
175
257
|
<kbd className="fd-search-kbd" onClick={() => onOpenChange(false)}>
|
|
@@ -193,7 +275,12 @@ export function PagefindSearch({
|
|
|
193
275
|
<a
|
|
194
276
|
key={i}
|
|
195
277
|
href={r.url}
|
|
196
|
-
|
|
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)}
|
|
197
284
|
onClick={() => onOpenChange(false)}
|
|
198
285
|
>
|
|
199
286
|
<span className="fd-search-result-title">
|
|
@@ -1,68 +1,68 @@
|
|
|
1
|
-
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
|
2
|
-
import { createElement } from 'react';
|
|
3
|
-
import { VersionSwitcher } from '@/components/version-switcher';
|
|
4
|
-
import { getExternalTabs, getNavbarAnchors, getSiteLogoAsset, getSiteName, getVersionOptions } from '@/lib/velu';
|
|
5
|
-
|
|
6
|
-
export function baseOptions(): BaseLayoutProps {
|
|
7
|
-
const externalTabs = getExternalTabs();
|
|
8
|
-
const navAnchors = getNavbarAnchors();
|
|
9
|
-
const versions = getVersionOptions();
|
|
10
|
-
const siteName = getSiteName();
|
|
11
|
-
const logo = getSiteLogoAsset();
|
|
12
|
-
const lightLogo = logo.light ?? logo.dark;
|
|
13
|
-
const darkLogo = logo.dark ?? logo.light;
|
|
14
|
-
const logoHref = typeof logo.href === 'string' && logo.href.trim().length > 0 ? logo.href.trim() : '/';
|
|
15
|
-
|
|
16
|
-
const navTitle =
|
|
17
|
-
lightLogo || darkLogo
|
|
18
|
-
? createElement(
|
|
19
|
-
'span',
|
|
20
|
-
{ className: 'velu-nav-brand' },
|
|
21
|
-
lightLogo
|
|
22
|
-
? createElement('img', {
|
|
23
|
-
src: lightLogo,
|
|
24
|
-
alt: siteName,
|
|
25
|
-
className: 'velu-nav-logo velu-nav-logo-light',
|
|
26
|
-
})
|
|
27
|
-
: null,
|
|
28
|
-
darkLogo
|
|
29
|
-
? createElement('img', {
|
|
30
|
-
src: darkLogo,
|
|
31
|
-
alt: siteName,
|
|
32
|
-
className: 'velu-nav-logo velu-nav-logo-dark',
|
|
33
|
-
})
|
|
34
|
-
: null,
|
|
35
|
-
)
|
|
36
|
-
: siteName;
|
|
37
|
-
|
|
38
|
-
const links = [
|
|
39
|
-
...externalTabs.map((tab: { label: string; href: string }) => ({
|
|
40
|
-
text: tab.label,
|
|
41
|
-
url: tab.href,
|
|
42
|
-
secondary: false,
|
|
43
|
-
})),
|
|
44
|
-
...navAnchors
|
|
45
|
-
.filter((a): a is { anchor: string; href: string } => typeof a.href === 'string' && a.href.length > 0)
|
|
46
|
-
.map((a) => ({
|
|
47
|
-
text: a.anchor,
|
|
48
|
-
url: a.href,
|
|
49
|
-
secondary: true,
|
|
50
|
-
})),
|
|
51
|
-
];
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
nav: {
|
|
55
|
-
title: navTitle,
|
|
56
|
-
url: logoHref,
|
|
57
|
-
children:
|
|
58
|
-
versions.length > 1
|
|
59
|
-
? createElement(
|
|
60
|
-
'div',
|
|
61
|
-
{ className: 'velu-header-version-switcher' },
|
|
62
|
-
createElement(VersionSwitcher, { versions })
|
|
63
|
-
)
|
|
64
|
-
: undefined,
|
|
65
|
-
},
|
|
66
|
-
links,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
1
|
+
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
|
2
|
+
import { createElement } from 'react';
|
|
3
|
+
import { VersionSwitcher } from '@/components/version-switcher';
|
|
4
|
+
import { getExternalTabs, getNavbarAnchors, getSiteLogoAsset, getSiteName, getVersionOptions } from '@/lib/velu';
|
|
5
|
+
|
|
6
|
+
export function baseOptions(): BaseLayoutProps {
|
|
7
|
+
const externalTabs = getExternalTabs();
|
|
8
|
+
const navAnchors = getNavbarAnchors();
|
|
9
|
+
const versions = getVersionOptions();
|
|
10
|
+
const siteName = getSiteName();
|
|
11
|
+
const logo = getSiteLogoAsset();
|
|
12
|
+
const lightLogo = logo.light ?? logo.dark;
|
|
13
|
+
const darkLogo = logo.dark ?? logo.light;
|
|
14
|
+
const logoHref = typeof logo.href === 'string' && logo.href.trim().length > 0 ? logo.href.trim() : '/';
|
|
15
|
+
|
|
16
|
+
const navTitle =
|
|
17
|
+
lightLogo || darkLogo
|
|
18
|
+
? createElement(
|
|
19
|
+
'span',
|
|
20
|
+
{ className: 'velu-nav-brand' },
|
|
21
|
+
lightLogo
|
|
22
|
+
? createElement('img', {
|
|
23
|
+
src: lightLogo,
|
|
24
|
+
alt: siteName,
|
|
25
|
+
className: 'velu-nav-logo velu-nav-logo-light',
|
|
26
|
+
})
|
|
27
|
+
: null,
|
|
28
|
+
darkLogo
|
|
29
|
+
? createElement('img', {
|
|
30
|
+
src: darkLogo,
|
|
31
|
+
alt: siteName,
|
|
32
|
+
className: 'velu-nav-logo velu-nav-logo-dark',
|
|
33
|
+
})
|
|
34
|
+
: null,
|
|
35
|
+
)
|
|
36
|
+
: siteName;
|
|
37
|
+
|
|
38
|
+
const links = [
|
|
39
|
+
...externalTabs.map((tab: { label: string; href: string }) => ({
|
|
40
|
+
text: tab.label,
|
|
41
|
+
url: tab.href,
|
|
42
|
+
secondary: false,
|
|
43
|
+
})),
|
|
44
|
+
...navAnchors
|
|
45
|
+
.filter((a): a is { anchor: string; href: string } => typeof a.href === 'string' && a.href.length > 0)
|
|
46
|
+
.map((a) => ({
|
|
47
|
+
text: a.anchor,
|
|
48
|
+
url: a.href,
|
|
49
|
+
secondary: true,
|
|
50
|
+
})),
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
nav: {
|
|
55
|
+
title: navTitle,
|
|
56
|
+
url: logoHref,
|
|
57
|
+
children:
|
|
58
|
+
versions.length > 1
|
|
59
|
+
? createElement(
|
|
60
|
+
'div',
|
|
61
|
+
{ className: 'velu-header-version-switcher' },
|
|
62
|
+
createElement(VersionSwitcher, { versions })
|
|
63
|
+
)
|
|
64
|
+
: undefined,
|
|
65
|
+
},
|
|
66
|
+
links,
|
|
67
|
+
};
|
|
68
|
+
}
|