@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,66 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { NextResponse } from 'next/server';
3
+ import redirectRules from '@/generated/redirects';
4
+ import {
5
+ compileRedirectRules,
6
+ isExternalDestination,
7
+ normalizeRedirectRules,
8
+ resolveRedirect,
9
+ } from '@/lib/redirects';
10
+
11
+ const compiledRedirects = compileRedirectRules(normalizeRedirectRules(redirectRules));
12
+
13
+ export function middleware(request: NextRequest) {
14
+ const { pathname } = request.nextUrl;
15
+
16
+ if (pathname.startsWith('/rss-file')) {
17
+ return NextResponse.next();
18
+ }
19
+ if (pathname.startsWith('/llms-file') || pathname.startsWith('/llms-full-file')) {
20
+ return NextResponse.next();
21
+ }
22
+
23
+ if (pathname.endsWith('/rss.xml')) {
24
+ const rewritten = request.nextUrl.clone();
25
+ rewritten.pathname = `/rss-file${pathname.slice(0, -('/rss.xml'.length))}`;
26
+ return NextResponse.rewrite(rewritten);
27
+ }
28
+
29
+ if (pathname === '/llms.txt') {
30
+ const rewritten = request.nextUrl.clone();
31
+ rewritten.pathname = '/llms-file';
32
+ return NextResponse.rewrite(rewritten);
33
+ }
34
+
35
+ if (pathname === '/llms-full.txt') {
36
+ const rewritten = request.nextUrl.clone();
37
+ rewritten.pathname = '/llms-full-file';
38
+ return NextResponse.rewrite(rewritten);
39
+ }
40
+
41
+ if (pathname.endsWith('.md')) {
42
+ const rewritten = request.nextUrl.clone();
43
+ rewritten.pathname = `/md-file${pathname}`;
44
+ return NextResponse.rewrite(rewritten);
45
+ }
46
+
47
+ const redirect = resolveRedirect(pathname, compiledRedirects);
48
+ if (redirect) {
49
+ if (isExternalDestination(redirect.destination)) {
50
+ return NextResponse.redirect(redirect.destination, redirect.statusCode);
51
+ }
52
+
53
+ const target = request.nextUrl.clone();
54
+ target.pathname = redirect.destination;
55
+ if (!target.search && request.nextUrl.search) {
56
+ target.search = request.nextUrl.search;
57
+ }
58
+ return NextResponse.redirect(target, redirect.statusCode);
59
+ }
60
+
61
+ return NextResponse.next();
62
+ }
63
+
64
+ export const config = {
65
+ matcher: ['/((?!_next|favicon.ico|sitemap.xml|robots.txt|assets|images).*)'],
66
+ };
@@ -7,8 +7,8 @@ const withMDX = createMDX({
7
7
 
8
8
  /** @type {import('next').NextConfig} */
9
9
  const config = {
10
- reactStrictMode: true,
11
- output: 'export',
10
+ reactStrictMode: false,
11
+ output: process.env.NODE_ENV === 'production' ? 'export' : undefined,
12
12
  distDir: 'dist',
13
13
  devIndicators: false,
14
14
  turbopack: {
@@ -0,0 +1,12 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09">
3
+ <!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
4
+ <defs>
5
+ <style>
6
+ .st0 {
7
+ fill: #edecec;
8
+ }
9
+ </style>
10
+ </defs>
11
+ <path class="st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
12
+ </svg>
@@ -0,0 +1,12 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09">
3
+ <!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
4
+ <defs>
5
+ <style>
6
+ .st0 {
7
+ fill: #26251e;
8
+ }
9
+ </style>
10
+ </defs>
11
+ <path class="st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
12
+ </svg>
@@ -1,5 +1,89 @@
1
1
  import { defineConfig, defineDocs } from 'fumadocs-mdx/config';
2
2
  import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';
3
+ import { transformerMetaHighlight } from '@shikijs/transformers';
4
+
5
+ function remarkCodeFilenameToTitle() {
6
+ const booleanMetaFlags = new Set([
7
+ 'wrap',
8
+ 'copy',
9
+ 'nocopy',
10
+ 'lineNumbers',
11
+ 'linenumbers',
12
+ 'showLineNumbers',
13
+ ]);
14
+
15
+ function quoteTitle(value: string): string {
16
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
17
+ }
18
+
19
+ function ensureTitleMeta(meta: string): string {
20
+ const trimmed = meta.trim();
21
+ if (!trimmed) return trimmed;
22
+ if (/\btitle\s*=/.test(trimmed)) return trimmed;
23
+
24
+ const fileWithRest = trimmed.match(/^([^\s]+?\.[a-z0-9_-]+)(\s+.*)?$/i);
25
+ if (fileWithRest) {
26
+ const file = fileWithRest[1];
27
+ const rest = (fileWithRest[2] ?? '').trim();
28
+ return rest ? `title="${quoteTitle(file)}" ${rest}` : `title="${quoteTitle(file)}"`;
29
+ }
30
+
31
+ if (!trimmed.includes('=') && !trimmed.includes('{') && !trimmed.includes('}')) {
32
+ if (booleanMetaFlags.has(trimmed)) return trimmed;
33
+ return `title="${quoteTitle(trimmed)}"`;
34
+ }
35
+
36
+ return trimmed;
37
+ }
38
+
39
+ function visit(node: any) {
40
+ if (!node || typeof node !== 'object') return;
41
+
42
+ if (node.type === 'code' && typeof node.meta === 'string') {
43
+ let meta = node.meta.trim();
44
+ // Mint-style fence syntax: ```lang filename.ext
45
+ // Convert it into title metadata so code tabs can use file names.
46
+ meta = ensureTitleMeta(meta);
47
+
48
+ // Mint-style line highlight syntax: highlight=1 or highlight="1,3-5"
49
+ // Convert to Shiki meta-highlight format: {1,3-5}
50
+ const hlMatch = meta.match(/(?:^|\s)highlight=(?:"([^"]+)"|'([^']+)'|([^\s]+))/i);
51
+ if (hlMatch) {
52
+ const raw = (hlMatch[1] ?? hlMatch[2] ?? hlMatch[3] ?? '').trim();
53
+ const lineSpec = raw.replace(/[{}]/g, '');
54
+ meta = meta.replace(hlMatch[0], '').replace(/\s+/g, ' ').trim();
55
+ if (lineSpec && !/\{\s*\d[\d,\-\s]*\s*\}/.test(meta)) {
56
+ meta = `${meta} {${lineSpec}}`.trim();
57
+ }
58
+ }
59
+
60
+ // theme={null} is a Mint docs hint; remove it from fence meta.
61
+ meta = meta.replace(/\btheme=\{null\}\b/g, '').replace(/\s+/g, ' ').trim();
62
+ node.meta = meta;
63
+ }
64
+ if (node.type === 'code' && typeof node.meta !== 'string') {
65
+ return;
66
+ }
67
+
68
+ if (node.type === 'code' && node.meta === '') {
69
+ delete node.meta;
70
+ }
71
+
72
+ if (node.type === 'code' && typeof node.meta === 'string') {
73
+ node.meta = node.meta.trim();
74
+ if (!node.meta) {
75
+ delete node.meta;
76
+ }
77
+ }
78
+
79
+ const children = node.children;
80
+ if (Array.isArray(children)) {
81
+ for (const child of children) visit(child);
82
+ }
83
+ }
84
+
85
+ return (tree: any) => visit(tree);
86
+ }
3
87
 
4
88
  export const docs = defineDocs({
5
89
  dir: 'content/docs',
@@ -14,4 +98,17 @@ export const docs = defineDocs({
14
98
  },
15
99
  });
16
100
 
17
- export default defineConfig();
101
+ export default defineConfig({
102
+ mdxOptions: {
103
+ remarkPlugins: [remarkCodeFilenameToTitle],
104
+ rehypeCodeOptions: ({
105
+ lazy: false,
106
+ fallbackLanguage: 'bash',
107
+ transformers: [transformerMetaHighlight()],
108
+ langAlias: {
109
+ gradle: 'groovy',
110
+ proguard: 'properties',
111
+ },
112
+ } as any),
113
+ },
114
+ });
@@ -405,19 +405,30 @@ const title = Astro.locals.starlightRoute.entry.data.title;
405
405
  }
406
406
  }, true);
407
407
 
408
- // Hide ask bar only when user scrolls to the very bottom
409
- window.addEventListener('scroll', function() {
408
+ // Hide ask bar only when truly near the page bottom.
409
+ function syncAskBarVisibility() {
410
410
  if (isPanelOpen()) return;
411
411
  var scrollTop = window.pageYOffset || document.documentElement.scrollTop;
412
412
  var docHeight = document.documentElement.scrollHeight;
413
413
  var winHeight = window.innerHeight;
414
- if (docHeight <= winHeight + 10) return; // short pages: always show
415
- if (docHeight - scrollTop - winHeight < 60) {
414
+ var bottomGap = docHeight - scrollTop - winHeight;
415
+ var nearBottomThreshold = 8;
416
+
417
+ if (docHeight <= winHeight + 2) {
418
+ askBar.classList.remove('velu-ask-bar-hidden');
419
+ return;
420
+ }
421
+
422
+ if (bottomGap <= nearBottomThreshold) {
416
423
  askBar.classList.add('velu-ask-bar-hidden');
417
424
  } else {
418
425
  askBar.classList.remove('velu-ask-bar-hidden');
419
426
  }
420
- }, { passive: true });
427
+ }
428
+
429
+ window.addEventListener('scroll', syncAskBarVisibility, { passive: true });
430
+ window.addEventListener('resize', syncAskBarVisibility, { passive: true });
431
+ syncAskBarVisibility();
421
432
 
422
433
  document.onkeydown = function(e) {
423
434
  if (e.key === 'Escape' && isPanelOpen()) { closePanel(); }
@@ -1,15 +1,54 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
+ import { normalizeConfigNavigation } from '../../lib/navigation-normalize';
4
+ const PRIMARY_CONFIG_NAME = 'docs.json';
5
+ const LEGACY_CONFIG_NAME = 'velu.json';
6
+
7
+ function resolveConfigPath(cwd: string): string {
8
+ const primary = resolve(cwd, PRIMARY_CONFIG_NAME);
9
+ if (existsSync(primary)) return primary;
10
+ return resolve(cwd, LEGACY_CONFIG_NAME);
11
+ }
3
12
 
4
13
  // ── Types ───────────────────────────────────────────────────────────────────
5
14
 
15
+ export interface VeluSeparator {
16
+ separator: string;
17
+ }
18
+
19
+ export interface VeluLink {
20
+ href: string;
21
+ label: string;
22
+ icon?: string;
23
+ }
24
+
25
+ export interface VeluAnchor {
26
+ anchor: string;
27
+ href?: string;
28
+ icon?: string;
29
+ color?: {
30
+ light: string;
31
+ dark: string;
32
+ };
33
+ tabs?: VeluTab[];
34
+ hidden?: boolean;
35
+ }
36
+
37
+ export interface VeluGlobalTab {
38
+ tab: string;
39
+ href: string;
40
+ icon?: string;
41
+ }
42
+
6
43
  export interface VeluGroup {
7
44
  group: string;
8
45
  slug: string;
9
46
  icon?: string;
10
47
  tag?: string;
11
48
  expanded?: boolean;
12
- pages: (string | VeluGroup)[];
49
+ description?: string;
50
+ hidden?: boolean;
51
+ pages: (string | VeluGroup | VeluSeparator | VeluLink)[];
13
52
  }
14
53
 
15
54
  export interface VeluTab {
@@ -17,7 +56,7 @@ export interface VeluTab {
17
56
  slug: string;
18
57
  icon?: string;
19
58
  href?: string;
20
- pages?: string[];
59
+ pages?: (string | VeluSeparator | VeluLink)[];
21
60
  groups?: VeluGroup[];
22
61
  }
23
62
 
@@ -28,7 +67,12 @@ export interface VeluConfig {
28
67
  appearance?: 'system' | 'light' | 'dark';
29
68
  styling?: { codeblocks?: { theme?: string | { light: string; dark: string } } };
30
69
  navigation: {
31
- tabs: VeluTab[];
70
+ tabs?: VeluTab[];
71
+ anchors?: VeluAnchor[];
72
+ global?: {
73
+ anchors?: VeluAnchor[];
74
+ tabs?: VeluGlobalTab[];
75
+ };
32
76
  };
33
77
  }
34
78
 
@@ -46,9 +90,9 @@ let _cachedConfig: VeluConfig | null = null;
46
90
 
47
91
  export function loadVeluConfig(): VeluConfig {
48
92
  if (_cachedConfig) return _cachedConfig;
49
- const configPath = resolve(process.cwd(), 'velu.json');
93
+ const configPath = resolveConfigPath(process.cwd());
50
94
  const raw = readFileSync(configPath, 'utf-8');
51
- _cachedConfig = JSON.parse(raw);
95
+ _cachedConfig = normalizeConfigNavigation(JSON.parse(raw));
52
96
  return _cachedConfig!;
53
97
  }
54
98
 
@@ -64,7 +108,7 @@ function veluGroupToSidebar(group: VeluGroup, tabSlug: string): any {
64
108
  for (const item of group.pages) {
65
109
  if (typeof item === 'string') {
66
110
  items.push(tabSlug + '/' + group.slug + '/' + pageBasename(item));
67
- } else {
111
+ } else if (isGroup(item)) {
68
112
  items.push(veluGroupToSidebar(item, tabSlug));
69
113
  }
70
114
  }
@@ -74,10 +118,18 @@ function veluGroupToSidebar(group: VeluGroup, tabSlug: string): any {
74
118
  return result;
75
119
  }
76
120
 
121
+ function isGroup(item: unknown): item is VeluGroup {
122
+ return typeof item === 'object' && item !== null && 'group' in item;
123
+ }
124
+
77
125
  /** Get the first page dest path for a tab */
78
126
  function firstTabPage(tab: VeluTab): string | undefined {
79
- if (tab.pages && tab.pages.length > 0) {
80
- return tab.slug + '/' + pageBasename(tab.pages[0]);
127
+ if (tab.pages) {
128
+ for (const item of tab.pages) {
129
+ if (typeof item === 'string') {
130
+ return tab.slug + '/' + pageBasename(item);
131
+ }
132
+ }
81
133
  }
82
134
  if (tab.groups) {
83
135
  for (const g of tab.groups) {
@@ -91,25 +143,29 @@ function firstTabPage(tab: VeluTab): string | undefined {
91
143
  function firstGroupPage(group: VeluGroup, tabSlug: string): string | undefined {
92
144
  for (const item of group.pages) {
93
145
  if (typeof item === 'string') return tabSlug + '/' + group.slug + '/' + pageBasename(item);
94
- const nested = firstGroupPage(item, tabSlug);
95
- if (nested) return nested;
146
+ if (isGroup(item)) {
147
+ const nested = firstGroupPage(item, tabSlug);
148
+ if (nested) return nested;
149
+ }
96
150
  }
97
151
  return undefined;
98
152
  }
99
153
 
100
154
  // ── Public API ──────────────────────────────────────────────────────────────
101
155
 
102
- /** Build the full Starlight sidebar array from velu.json */
156
+ /** Build the full Starlight sidebar array from docs.json/velu.json */
103
157
  export function getSidebar(): any[] {
104
158
  const config = loadVeluConfig();
105
159
  const sidebar: any[] = [];
106
160
 
107
- for (const tab of config.navigation.tabs) {
161
+ for (const tab of config.navigation.tabs ?? []) {
108
162
  if (tab.href) continue;
109
163
  const items: any[] = [];
110
164
  if (tab.groups) for (const g of tab.groups) items.push(veluGroupToSidebar(g, tab.slug));
111
165
  if (tab.pages) {
112
- for (const p of tab.pages) items.push(tab.slug + '/' + pageBasename(p));
166
+ for (const p of tab.pages) {
167
+ if (typeof p === 'string') items.push(tab.slug + '/' + pageBasename(p));
168
+ }
113
169
  }
114
170
  sidebar.push({ label: tab.tab, items });
115
171
  }
@@ -122,7 +178,7 @@ export function getTabs(): TabMeta[] {
122
178
  const config = loadVeluConfig();
123
179
  const tabs: TabMeta[] = [];
124
180
 
125
- for (const tab of config.navigation.tabs) {
181
+ for (const tab of config.navigation.tabs ?? []) {
126
182
  if (tab.href) {
127
183
  tabs.push({ label: tab.tab, icon: tab.icon, href: tab.href, slugs: [] });
128
184
  } else {
@@ -144,10 +200,35 @@ export function getTabSidebarMap(): Record<string, string[]> {
144
200
  const config = loadVeluConfig();
145
201
  const map: Record<string, string[]> = {};
146
202
 
147
- for (const tab of config.navigation.tabs) {
203
+ for (const tab of config.navigation.tabs ?? []) {
148
204
  if (tab.href) continue;
149
205
  map[tab.slug] = [tab.tab];
150
206
  }
151
207
 
152
208
  return map;
153
209
  }
210
+
211
+ /** Get all anchors (navigation.anchors + navigation.global.anchors), excluding hidden ones */
212
+ export function getAnchors(): VeluAnchor[] {
213
+ const config = loadVeluConfig();
214
+ const anchors: VeluAnchor[] = [];
215
+ if (config.navigation.anchors) {
216
+ anchors.push(...config.navigation.anchors.filter((a) => typeof a.href === 'string' && a.href.length > 0 && !a.hidden));
217
+ }
218
+ if (config.navigation.global?.anchors) {
219
+ anchors.push(...config.navigation.global.anchors.filter((a) => typeof a.href === 'string' && a.href.length > 0 && !a.hidden));
220
+ }
221
+ return anchors;
222
+ }
223
+
224
+ /** Get external tab links for the navbar */
225
+ export function getExternalTabs(): { label: string; href: string; icon?: string }[] {
226
+ const config = loadVeluConfig();
227
+ const tabLinks = (config.navigation.tabs ?? [])
228
+ .filter((tab) => !!tab.href)
229
+ .map((tab) => ({ label: tab.tab, href: tab.href!, icon: tab.icon }));
230
+ const globalLinks = (config.navigation.global?.tabs ?? [])
231
+ .filter((tab) => !!tab.href)
232
+ .map((tab) => ({ label: tab.tab, href: tab.href, icon: tab.icon }));
233
+ return [...tabLinks, ...globalLinks];
234
+ }