@aravindc26/velu 0.11.5 → 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,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
+ }
@@ -107,12 +107,37 @@ interface VeluSeoConfig {
107
107
  indexing?: 'navigable' | 'all' | string;
108
108
  }
109
109
 
110
+ interface VeluMetadataConfig {
111
+ timestamp?: boolean;
112
+ }
113
+
110
114
  interface VeluThemeAsset {
111
115
  light?: string;
112
116
  dark?: string;
113
117
  href?: string;
114
118
  }
115
119
 
120
+ interface VeluFooterConfig {
121
+ socials?: Record<string, unknown> | VeluFooterSocialInput[];
122
+ }
123
+
124
+ interface VeluFooterSocialInput {
125
+ type?: string;
126
+ label?: string;
127
+ href?: string;
128
+ url?: string;
129
+ icon?: string;
130
+ iconType?: string;
131
+ }
132
+
133
+ export interface VeluFooterSocialLink {
134
+ key: string;
135
+ label: string;
136
+ href: string;
137
+ icon: string;
138
+ iconType?: string;
139
+ }
140
+
116
141
  export interface VeluProductOption {
117
142
  product: string;
118
143
  slug: string;
@@ -123,6 +148,16 @@ export interface VeluProductOption {
123
148
  defaultPath: string;
124
149
  }
125
150
 
151
+ export interface VeluDropdownOption {
152
+ dropdown: string;
153
+ slug: string;
154
+ description?: string;
155
+ icon?: string;
156
+ iconType?: string;
157
+ tabSlugs: string[];
158
+ defaultPath: string;
159
+ }
160
+
126
161
  export interface VeluVersionOption {
127
162
  version: string;
128
163
  slug: string;
@@ -131,8 +166,16 @@ export interface VeluVersionOption {
131
166
  defaultPath: string;
132
167
  }
133
168
 
169
+ interface VeluContextualCustomOption {
170
+ title: string;
171
+ description?: string;
172
+ icon?: string;
173
+ href: string;
174
+ }
175
+
134
176
  interface VeluConfig {
135
177
  name?: string;
178
+ description?: string;
136
179
  title?: string;
137
180
  favicon?: string | VeluThemeAsset;
138
181
  logo?: string | VeluThemeAsset;
@@ -150,6 +193,13 @@ interface VeluConfig {
150
193
  asyncapi?: string | string[] | Record<string, unknown>;
151
194
  api?: VeluApiConfig;
152
195
  seo?: VeluSeoConfig;
196
+ metadata?: VeluMetadataConfig;
197
+ footer?: VeluFooterConfig;
198
+ footerSocials?: Record<string, unknown> | VeluFooterSocialInput[];
199
+ banner?: { content?: string; dismissible?: boolean };
200
+ contextual?: {
201
+ options?: Array<string | VeluContextualCustomOption>;
202
+ };
153
203
  navigation: {
154
204
  tabs?: VeluTab[];
155
205
  languages?: VeluLanguageNav[];
@@ -211,6 +261,12 @@ function pageBasename(page: string): string {
211
261
  return parts[parts.length - 1] ?? page;
212
262
  }
213
263
 
264
+ function withTrailingSlashPath(path: string): string {
265
+ if (!path.startsWith('/')) return path;
266
+ if (path === '/' || path.endsWith('/')) return path;
267
+ return `${path}/`;
268
+ }
269
+
214
270
  function findFirstPageInGroup(group: VeluGroup): string | undefined {
215
271
  for (const item of group.pages) {
216
272
  if (typeof item === 'string') return item;
@@ -237,6 +293,41 @@ function findFirstPageInTab(tab: VeluTab): string | undefined {
237
293
  return undefined;
238
294
  }
239
295
 
296
+ function findFirstRouteInGroup(group: VeluGroup, tabSlug: string, parentGroupSlugs: string[] = []): string | undefined {
297
+ const nextGroupSlugs = group.slug ? [...parentGroupSlugs, group.slug] : parentGroupSlugs;
298
+
299
+ for (const item of group.pages) {
300
+ if (typeof item === 'string') {
301
+ return `/${[tabSlug, ...nextGroupSlugs, pageBasename(item)].filter(Boolean).join('/')}`;
302
+ }
303
+ if (isGroup(item)) {
304
+ const nested = findFirstRouteInGroup(item, tabSlug, nextGroupSlugs);
305
+ if (nested) return nested;
306
+ }
307
+ }
308
+ return undefined;
309
+ }
310
+
311
+ function resolveFirstRouteInTab(tab: VeluTab): string | undefined {
312
+ if (!tab.slug) return undefined;
313
+
314
+ if (tab.pages) {
315
+ for (const item of tab.pages) {
316
+ if (typeof item === 'string') return `/${tab.slug}/${pageBasename(item)}`;
317
+ }
318
+ }
319
+
320
+ if (tab.groups) {
321
+ for (const group of tab.groups) {
322
+ const nested = findFirstRouteInGroup(group, tab.slug);
323
+ if (nested) return nested;
324
+ }
325
+ }
326
+
327
+ if (typeof tab.href === 'string' && tab.href.trim().length > 0) return tab.href.trim();
328
+ return `/${tab.slug}`;
329
+ }
330
+
240
331
  function parseVersionParts(version: string): number[] {
241
332
  const parts = version.match(/\d+/g);
242
333
  return parts ? parts.map((n) => Number(n)) : [];
@@ -252,6 +343,109 @@ function compareVersionParts(a: number[], b: number[]): number {
252
343
  return 0;
253
344
  }
254
345
 
346
+ const FOOTER_SOCIAL_PRESETS: Record<string, { label: string; icon: string }> = {
347
+ x: { label: 'X', icon: 'x-twitter' },
348
+ twitter: { label: 'X', icon: 'x-twitter' },
349
+ github: { label: 'GitHub', icon: 'github' },
350
+ gitlab: { label: 'GitLab', icon: 'gitlab' },
351
+ linkedin: { label: 'LinkedIn', icon: 'linkedin' },
352
+ discord: { label: 'Discord', icon: 'discord' },
353
+ youtube: { label: 'YouTube', icon: 'youtube' },
354
+ facebook: { label: 'Facebook', icon: 'facebook' },
355
+ instagram: { label: 'Instagram', icon: 'instagram' },
356
+ slack: { label: 'Slack', icon: 'slack' },
357
+ medium: { label: 'Medium', icon: 'medium' },
358
+ reddit: { label: 'Reddit', icon: 'reddit' },
359
+ };
360
+
361
+ function normalizeSocialKey(value: string): string {
362
+ return value.trim().toLowerCase().replace(/[\s_]+/g, '-');
363
+ }
364
+
365
+ function titleCaseWords(value: string): string {
366
+ const normalized = value.trim().replace(/[_-]+/g, ' ');
367
+ if (!normalized) return 'Social';
368
+ return normalized
369
+ .split(/\s+/)
370
+ .filter(Boolean)
371
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
372
+ .join(' ');
373
+ }
374
+
375
+ function trimString(value: unknown): string | undefined {
376
+ if (typeof value !== 'string') return undefined;
377
+ const trimmed = value.trim();
378
+ return trimmed.length > 0 ? trimmed : undefined;
379
+ }
380
+
381
+ function normalizeFooterSocialMap(value: unknown): VeluFooterSocialLink[] {
382
+ if (!isRecord(value)) return [];
383
+ const links: VeluFooterSocialLink[] = [];
384
+ for (const [rawKey, rawHref] of Object.entries(value)) {
385
+ const href = trimString(rawHref);
386
+ if (!href) continue;
387
+ const key = normalizeSocialKey(rawKey);
388
+ if (!key) continue;
389
+ const preset = FOOTER_SOCIAL_PRESETS[key];
390
+ links.push({
391
+ key,
392
+ href,
393
+ label: preset?.label ?? titleCaseWords(key),
394
+ icon: preset?.icon ?? key,
395
+ iconType: 'brands',
396
+ });
397
+ }
398
+ return links;
399
+ }
400
+
401
+ function normalizeFooterSocialList(value: unknown): VeluFooterSocialLink[] {
402
+ if (!Array.isArray(value)) return [];
403
+ const links: VeluFooterSocialLink[] = [];
404
+ for (const entry of value) {
405
+ if (!isRecord(entry)) continue;
406
+ const href = trimString(entry.href) ?? trimString(entry.url);
407
+ if (!href) continue;
408
+ const key = normalizeSocialKey(
409
+ trimString(entry.type) ?? trimString(entry.icon) ?? trimString(entry.label) ?? 'social',
410
+ );
411
+ const preset = FOOTER_SOCIAL_PRESETS[key];
412
+ links.push({
413
+ key,
414
+ href,
415
+ label: trimString(entry.label) ?? preset?.label ?? titleCaseWords(key),
416
+ icon: trimString(entry.icon) ?? preset?.icon ?? key,
417
+ iconType: trimString(entry.iconType) ?? 'brands',
418
+ });
419
+ }
420
+ return links;
421
+ }
422
+
423
+ function dedupeFooterSocialLinks(links: VeluFooterSocialLink[]): VeluFooterSocialLink[] {
424
+ const seen = new Set<string>();
425
+ const unique: VeluFooterSocialLink[] = [];
426
+ for (const link of links) {
427
+ const dedupeKey = `${link.href}::${link.icon}`;
428
+ if (seen.has(dedupeKey)) continue;
429
+ seen.add(dedupeKey);
430
+ unique.push(link);
431
+ }
432
+ return unique;
433
+ }
434
+
435
+ export interface VeluBannerConfig {
436
+ content: string;
437
+ dismissible: boolean;
438
+ }
439
+
440
+ export function getBannerConfig(): VeluBannerConfig | null {
441
+ const config = loadVeluConfig();
442
+ const banner = config.banner;
443
+ if (!banner) return null;
444
+ const content = typeof banner.content === 'string' ? banner.content.trim() : '';
445
+ if (!content) return null;
446
+ return { content, dismissible: banner.dismissible === true };
447
+ }
448
+
255
449
  export function getExternalTabs(): Array<{ label: string; href: string }> {
256
450
  const config = loadVeluConfig();
257
451
  const tabs = config.navigation?.tabs ?? [];
@@ -288,6 +482,14 @@ export function getGlobalAnchors(): VeluAnchor[] {
288
482
  );
289
483
  }
290
484
 
485
+ export function getFooterSocials(): VeluFooterSocialLink[] {
486
+ const config = loadVeluConfig();
487
+ const source = config.footer?.socials ?? config.footerSocials;
488
+ const fromMap = normalizeFooterSocialMap(source);
489
+ const fromList = normalizeFooterSocialList(source);
490
+ return dedupeFooterSocialLinks([...fromMap, ...fromList]);
491
+ }
492
+
291
493
  export interface VeluTabMenuDefinition {
292
494
  tab: string;
293
495
  items: Array<{
@@ -351,6 +553,46 @@ export function getLanguages(): string[] {
351
553
  return config.languages ?? [];
352
554
  }
353
555
 
556
+ export function getDropdownOptions(): VeluDropdownOption[] {
557
+ const raw = loadRawConfig();
558
+ const navigation = isRecord(raw.navigation) ? raw.navigation : {};
559
+ const dropdowns = Array.isArray(navigation.dropdowns) ? navigation.dropdowns : [];
560
+ if (dropdowns.length === 0) return [];
561
+
562
+ const allTabs = loadVeluConfig().navigation.tabs ?? [];
563
+ const out: VeluDropdownOption[] = [];
564
+
565
+ dropdowns.forEach((entry, index) => {
566
+ if (!isRecord(entry)) return;
567
+ const dropdown = trimString(entry.dropdown);
568
+ if (!dropdown) return;
569
+
570
+ const slug = slugify(trimString(entry.slug) ?? dropdown, `dropdown-${index + 1}`);
571
+ const scopedTabs = allTabs.filter((tab) => {
572
+ const tabSlug = tab.slug ?? '';
573
+ return tabSlug === slug || tabSlug.startsWith(`${slug}/`);
574
+ });
575
+ const firstTab = scopedTabs[0];
576
+ const firstRoute = firstTab ? resolveFirstRouteInTab(firstTab) : undefined;
577
+ const href = trimString(entry.href);
578
+ const defaultPath = withTrailingSlashPath(firstRoute ?? href ?? `/${slug}`);
579
+
580
+ out.push({
581
+ dropdown,
582
+ slug,
583
+ description: trimString(entry.description),
584
+ icon: trimString(entry.icon),
585
+ iconType: trimString(entry.iconType),
586
+ tabSlugs: scopedTabs
587
+ .map((tab) => tab.slug)
588
+ .filter((tabSlug): tabSlug is string => typeof tabSlug === 'string' && tabSlug.length > 0),
589
+ defaultPath,
590
+ });
591
+ });
592
+
593
+ return out;
594
+ }
595
+
354
596
  export function getProductOptions(): VeluProductOption[] {
355
597
  const config = loadVeluConfig();
356
598
  const products = (config.navigation.products ?? []).filter((p) => !p.hidden);
@@ -382,7 +624,7 @@ export function getProductOptions(): VeluProductOption[] {
382
624
  icon: product.icon,
383
625
  iconType: product.iconType,
384
626
  tabSlugs,
385
- defaultPath,
627
+ defaultPath: withTrailingSlashPath(defaultPath),
386
628
  };
387
629
  });
388
630
  }
@@ -417,7 +659,7 @@ export function getVersionOptions(): VeluVersionOption[] {
417
659
  explicitDefault: version.default === true,
418
660
  versionParts: parseVersionParts(version.version),
419
661
  tabSlugs,
420
- defaultPath,
662
+ defaultPath: withTrailingSlashPath(defaultPath),
421
663
  order: index,
422
664
  };
423
665
  });
@@ -478,6 +720,10 @@ export interface VeluResolvedSeoConfig {
478
720
  indexing: 'navigable' | 'all';
479
721
  }
480
722
 
723
+ export interface VeluResolvedMetadataConfig {
724
+ timestamp: boolean;
725
+ }
726
+
481
727
  function normalizePlaygroundDisplay(api: VeluApiConfig | undefined): PlaygroundDisplayMode {
482
728
  const display = api?.playground?.display;
483
729
  if (display === 'interactive' || display === 'simple' || display === 'none') return display;
@@ -623,6 +869,13 @@ export function getSeoConfig(): VeluResolvedSeoConfig {
623
869
  };
624
870
  }
625
871
 
872
+ export function getMetadataConfig(): VeluResolvedMetadataConfig {
873
+ const config = loadVeluConfig();
874
+ return {
875
+ timestamp: config.metadata?.timestamp === true,
876
+ };
877
+ }
878
+
626
879
  export function getSiteName(): string {
627
880
  const config = loadVeluConfig();
628
881
  const fromName = normalizeAssetPath(config.name);
@@ -632,6 +885,11 @@ export function getSiteName(): string {
632
885
  return 'Velu Docs';
633
886
  }
634
887
 
888
+ export function getSiteDescription(): string | undefined {
889
+ const config = loadVeluConfig();
890
+ return normalizeAssetPath(config.description);
891
+ }
892
+
635
893
  export function getSiteFavicon(): string | undefined {
636
894
  const config = loadVeluConfig();
637
895
  const asset = normalizeThemeAsset(config.favicon);
@@ -650,6 +908,51 @@ export function getSitePrimaryColor(): string | undefined {
650
908
  return normalizeAssetPath(colors.primary) ?? normalizeAssetPath(colors.light) ?? normalizeAssetPath(colors.dark);
651
909
  }
652
910
 
911
+ export interface VeluContextualOption {
912
+ id: string;
913
+ title: string;
914
+ description: string;
915
+ href?: string;
916
+ type: 'builtin' | 'custom';
917
+ }
918
+
919
+ const CONTEXTUAL_PRESETS: Record<string, { title: string; description: string }> = {
920
+ copy: { title: 'Copy page', description: 'Copy page as Markdown for LLMs' },
921
+ view: { title: 'View as Markdown', description: 'View this page in Markdown format' },
922
+ chatgpt: { title: 'Open in ChatGPT', description: 'Ask questions about this page' },
923
+ claude: { title: 'Open in Claude', description: 'Ask questions about this page' },
924
+ perplexity: { title: 'Open in Perplexity', description: 'Ask questions about this page' },
925
+ grok: { title: 'Open in Grok', description: 'Ask questions about this page' },
926
+ mcp: { title: 'Copy MCP URL', description: 'Copy the MCP server URL' },
927
+ 'add-mcp': { title: 'Copy MCP install command', description: 'Copy npx command to install MCP server' },
928
+ cursor: { title: 'Connect to Cursor', description: 'Install MCP Server on Cursor' },
929
+ vscode: { title: 'Connect to VS Code', description: 'Install MCP Server on VS Code' },
930
+ };
931
+
932
+ const DEFAULT_CONTEXTUAL_OPTIONS = ['copy', 'chatgpt', 'claude', 'add-mcp', 'cursor', 'vscode'];
933
+
934
+ export function getContextualOptions(): VeluContextualOption[] {
935
+ const config = loadVeluConfig();
936
+ const raw = config.contextual?.options;
937
+
938
+ const ids = raw ?? DEFAULT_CONTEXTUAL_OPTIONS;
939
+
940
+ return ids.map((entry, index) => {
941
+ if (typeof entry === 'string') {
942
+ const preset = CONTEXTUAL_PRESETS[entry];
943
+ if (!preset) return null;
944
+ return { id: entry, title: preset.title, description: preset.description, type: 'builtin' as const };
945
+ }
946
+ return {
947
+ id: `custom-${index}`,
948
+ title: entry.title,
949
+ description: entry.description ?? '',
950
+ href: entry.href,
951
+ type: 'custom' as const,
952
+ };
953
+ }).filter((item): item is VeluContextualOption => item !== null);
954
+ }
955
+
653
956
  export function getSiteOrigin(): string {
654
957
  const seo = getSeoConfig();
655
958
  const envCandidates = [
@@ -9,6 +9,10 @@ const withMDX = createMDX({
9
9
  const config = {
10
10
  reactStrictMode: false,
11
11
  output: process.env.NODE_ENV === 'production' ? 'export' : undefined,
12
+ // For static hosts without rewrite rules, emit directory routes
13
+ // (e.g. /docs/page/index.html) so extensionless URLs resolve.
14
+ trailingSlash: true,
15
+ skipTrailingSlashRedirect: true,
12
16
  distDir: 'dist',
13
17
  devIndicators: false,
14
18
  turbopack: {
package/src/validate.ts CHANGED
@@ -115,6 +115,7 @@ type VeluOpenApiSource = string | string[] | Record<string, unknown>;
115
115
 
116
116
  interface VeluConfig {
117
117
  $schema?: string;
118
+ variables?: Record<string, string>;
118
119
  icons?: {
119
120
  library?: "fontawesome" | "lucide" | "tabler";
120
121
  };
@@ -145,6 +146,13 @@ interface VeluConfig {
145
146
  };
146
147
  };
147
148
  };
149
+ metadata?: {
150
+ timestamp?: boolean;
151
+ };
152
+ footer?: {
153
+ socials?: Record<string, unknown>;
154
+ };
155
+ footerSocials?: Record<string, unknown>;
148
156
  navigation: {
149
157
  openapi?: VeluOpenApiSource;
150
158
  asyncapi?: VeluOpenApiSource;