@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,7 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef, useState, useCallback, type ReactNode } from 'react';
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
- // 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
- }
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
- } 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;
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 filters;
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 pathname = usePathname();
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 current URL when search opens
117
+ // Extract page-level filters from rendered metadata when search opens.
89
118
  useEffect(() => {
90
- if (open && pathname) {
91
- const filters = extractFiltersFromPath(pathname);
92
- setActiveFilters(filters);
119
+ if (open) {
120
+ setActiveFilters(getActiveFiltersFromPage());
93
121
  }
94
- }, [open, pathname]);
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
- // Bypass bundler resolution — pagefind.js only exists in the static output
104
- const pf = await new Function('return import("/pagefind/pagefind.js")')();
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 response = await pagefindRef.current.search(q,
141
- Object.keys(filters).length > 0 ? { filters } : undefined
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
- response.results.slice(0, 8).map((r) => r.data())
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
- className="fd-search-result"
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
+ }