@aravindc26/velu 0.12.8 → 0.12.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.
Files changed (76) hide show
  1. package/package.json +1 -1
  2. package/src/build.ts +13 -0
  3. package/src/cli.ts +51 -9
  4. package/src/engine/app/(docs)/[...slug]/layout.tsx +21 -537
  5. package/src/engine/app/_preview/[sessionId]/[...slug]/layout.tsx +96 -0
  6. package/src/engine/app/_preview/[sessionId]/[...slug]/page.tsx +298 -0
  7. package/src/engine/app/_preview/[sessionId]/layout.tsx +56 -0
  8. package/src/{preview-engine/app → engine/app/_preview}/[sessionId]/page.tsx +7 -3
  9. package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/assets/[...path]/route.ts +1 -1
  10. package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/init/route.ts +2 -2
  11. package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/route.ts +3 -3
  12. package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/sync/route.ts +2 -2
  13. package/src/{preview-engine/app → engine/app/_preview}/layout.tsx +4 -1
  14. package/src/engine/app/global.css +0 -3623
  15. package/src/engine/app/layout.tsx +4 -3
  16. package/src/engine/components/sidebar-links.tsx +11 -5
  17. package/src/engine/lib/docs-layout.tsx +605 -0
  18. package/src/engine/lib/layout.shared.ts +7 -7
  19. package/src/engine/lib/preview-config.ts +129 -0
  20. package/src/{preview-engine/lib/content-generator.ts → engine/lib/preview-content.ts} +242 -42
  21. package/src/engine/lib/source.ts +80 -97
  22. package/src/engine/lib/velu.ts +79 -55
  23. package/src/engine/mdx-components.tsx +14 -650
  24. package/src/engine/source.config.ts +11 -89
  25. package/src/engine/tsconfig.json +1 -0
  26. package/src/engine-core/components/assistant.tsx +361 -0
  27. package/src/engine-core/components/banner.tsx +80 -0
  28. package/src/engine-core/components/changelog-filters.tsx +114 -0
  29. package/src/engine-core/components/code-group.tsx +383 -0
  30. package/src/engine-core/components/color.tsx +118 -0
  31. package/src/engine-core/components/copy-page.tsx +223 -0
  32. package/src/engine-core/components/dropdown-switcher.tsx +142 -0
  33. package/src/engine-core/components/expandable.tsx +77 -0
  34. package/src/engine-core/components/header-tab-link.tsx +43 -0
  35. package/src/engine-core/components/icon.tsx +136 -0
  36. package/src/engine-core/components/image-zoom-fallback.tsx +147 -0
  37. package/src/engine-core/components/image.tsx +111 -0
  38. package/src/engine-core/components/lang-switcher.tsx +101 -0
  39. package/src/engine-core/components/manual-api-playground.tsx +154 -0
  40. package/src/engine-core/components/mermaid.tsx +142 -0
  41. package/src/engine-core/components/openapi-toc-sync.tsx +59 -0
  42. package/src/engine-core/components/openapi.tsx +1682 -0
  43. package/src/engine-core/components/page-feedback-api.test.ts +83 -0
  44. package/src/engine-core/components/page-feedback-api.ts +89 -0
  45. package/src/engine-core/components/page-feedback.tsx +200 -0
  46. package/src/engine-core/components/product-switcher.tsx +107 -0
  47. package/src/engine-core/components/prompt.tsx +90 -0
  48. package/src/engine-core/components/providers.tsx +21 -0
  49. package/src/engine-core/components/search.tsx +318 -0
  50. package/src/engine-core/components/sidebar-links.tsx +54 -0
  51. package/src/engine-core/components/synced-tabs.tsx +57 -0
  52. package/src/engine-core/components/theme-toggle.tsx +39 -0
  53. package/src/engine-core/components/toc-examples.tsx +110 -0
  54. package/src/engine-core/components/version-switcher.tsx +95 -0
  55. package/src/engine-core/components/view.tsx +344 -0
  56. package/src/engine-core/css/assistant.css +326 -0
  57. package/src/engine-core/css/copy-page.css +206 -0
  58. package/src/engine-core/css/search.css +142 -0
  59. package/src/engine-core/css/shared.css +3628 -0
  60. package/src/engine-core/lib/remark-plugins.ts +102 -0
  61. package/src/engine-core/lib/source-plugins.ts +105 -0
  62. package/src/engine-core/mdx-components.tsx +654 -0
  63. package/src/engine-core/types.ts +49 -0
  64. package/src/preview-engine/app/[sessionId]/[...slug]/page.tsx +0 -41
  65. package/src/preview-engine/app/[sessionId]/layout.tsx +0 -26
  66. package/src/preview-engine/app/global.css +0 -29
  67. package/src/preview-engine/lib/session-config.ts +0 -86
  68. package/src/preview-engine/lib/session-layout.ts +0 -190
  69. package/src/preview-engine/lib/source.ts +0 -60
  70. package/src/preview-engine/next.config.mjs +0 -20
  71. package/src/preview-engine/postcss.config.mjs +0 -8
  72. package/src/preview-engine/source.config.ts +0 -26
  73. package/src/preview-engine/tsconfig.json +0 -32
  74. package/src/preview-engine/tsconfig.tsbuildinfo +0 -1
  75. /package/src/{preview-engine/app → engine/app/_preview}/page.tsx +0 -0
  76. /package/src/{preview-engine/lib/auth.ts → engine/lib/preview-auth.ts} +0 -0
@@ -5,9 +5,10 @@ import { Providers } from '@/components/providers';
5
5
  import { VeluAssistant } from '@/components/assistant';
6
6
  import { VeluBanner } from '@/components/banner';
7
7
  import './global.css';
8
- import './search.css';
9
- import './assistant.css';
10
- import './copy-page.css';
8
+ import '@core/css/shared.css';
9
+ import '@core/css/search.css';
10
+ import '@core/css/assistant.css';
11
+ import '@core/css/copy-page.css';
11
12
 
12
13
  function toAbsoluteUrl(origin: string, value: string): string {
13
14
  const trimmed = value.trim();
@@ -1,4 +1,4 @@
1
- import { getGlobalAnchors, getIconLibrary, getLanguages } from '@/lib/velu';
1
+ import { getGlobalAnchors, getIconLibrary, getLanguages, type VeluConfigSource, type VeluIconLibrary } from '@/lib/velu';
2
2
  import { ThemeToggle } from '@/components/theme-toggle';
3
3
  import { LanguageSwitcher } from '@/components/lang-switcher';
4
4
  import { VeluIcon } from '@/components/icon';
@@ -9,10 +9,16 @@ function ExternalLinkIcon() {
9
9
  );
10
10
  }
11
11
 
12
- export function SidebarLinks() {
13
- const anchors = getGlobalAnchors();
14
- const languages = getLanguages();
15
- const iconLibrary = getIconLibrary();
12
+ interface SidebarLinksProps {
13
+ anchors?: ReturnType<typeof getGlobalAnchors>;
14
+ iconLibrary?: VeluIconLibrary;
15
+ languages?: string[];
16
+ }
17
+
18
+ export function SidebarLinks(props: SidebarLinksProps) {
19
+ const anchors = props.anchors ?? getGlobalAnchors();
20
+ const languages = props.languages ?? getLanguages();
21
+ const iconLibrary = props.iconLibrary ?? getIconLibrary();
16
22
 
17
23
  return (
18
24
  <div className="velu-sidebar-footer">
@@ -0,0 +1,605 @@
1
+ /**
2
+ * Shared docs layout rendering logic.
3
+ * Used by both (docs)/[...slug]/layout.tsx (production) and _preview/ routes.
4
+ */
5
+ import { isValidElement, type ReactNode } from 'react';
6
+ import { DocsLayout } from 'fumadocs-ui/layouts/notebook';
7
+ import type { LinkItemType, BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
8
+ import { SidebarLinks } from '@/components/sidebar-links';
9
+ import { ProductSwitcher } from '@/components/product-switcher';
10
+ import { VeluIcon } from '@/components/icon';
11
+ import { HeaderTabLink } from '@/components/header-tab-link';
12
+ import type {
13
+ VeluVersionOption,
14
+ VeluProductOption,
15
+ VeluDropdownOption,
16
+ VeluTabMenuDefinition,
17
+ VeluIconLibrary,
18
+ VeluConfigSource,
19
+ } from '@/lib/velu';
20
+
21
+ // ── Tree node types ────────────────────────────────────────────────────────
22
+
23
+ interface PageTreePageNode {
24
+ type?: string;
25
+ url?: string;
26
+ external?: boolean;
27
+ }
28
+
29
+ interface PageTreeFolderNode {
30
+ type?: string;
31
+ name?: ReactNode;
32
+ icon?: ReactNode;
33
+ description?: ReactNode;
34
+ root?: boolean;
35
+ index?: { url?: string };
36
+ children?: unknown[];
37
+ }
38
+
39
+ // ── Utility functions ──────────────────────────────────────────────────────
40
+
41
+ function withTrailingSlashUrl(url: string): string {
42
+ const trimmed = url.trim();
43
+ if (trimmed.length === 0) return trimmed;
44
+ if (/^(https?:|mailto:|tel:|#)/i.test(trimmed)) return trimmed;
45
+
46
+ const hashIndex = trimmed.indexOf('#');
47
+ const queryIndex = trimmed.indexOf('?');
48
+ const endIndex = [hashIndex, queryIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0] ?? trimmed.length;
49
+ const path = trimmed.slice(0, endIndex);
50
+ const suffix = trimmed.slice(endIndex);
51
+
52
+ if (!path.startsWith('/')) return trimmed;
53
+ if (path === '/' || path.endsWith('/')) return `${path}${suffix}`;
54
+
55
+ const lastSegment = path.split('/').filter(Boolean).pop() ?? '';
56
+ if (lastSegment.includes('.')) return trimmed;
57
+
58
+ return `${path}/${suffix}`;
59
+ }
60
+
61
+ function normalizePath(value: string): string {
62
+ return value.replace(/^\/+|\/+$/g, '').toLowerCase();
63
+ }
64
+
65
+ function normalizeSidebarTabUrl(url: string): string {
66
+ const trimmed = url.trim();
67
+ if (trimmed.length <= 1) return trimmed;
68
+ return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed;
69
+ }
70
+
71
+ function basename(value: string): string {
72
+ const normalized = normalizePath(value);
73
+ const parts = normalized.split('/').filter(Boolean);
74
+ return parts[parts.length - 1] ?? normalized;
75
+ }
76
+
77
+ // ── Tree helpers ───────────────────────────────────────────────────────────
78
+
79
+ function collectFolderUrls(folder: PageTreeFolderNode, out: Set<string> = new Set<string>()): Set<string> {
80
+ if (typeof folder.index?.url === 'string' && folder.index.url.length > 0) out.add(folder.index.url);
81
+ for (const child of Array.isArray(folder.children) ? folder.children : []) {
82
+ const node = child as PageTreePageNode & PageTreeFolderNode;
83
+ if (node?.type === 'page' && !node.external && typeof node.url === 'string' && node.url.length > 0) {
84
+ out.add(node.url);
85
+ continue;
86
+ }
87
+ if (node?.type === 'folder') collectFolderUrls(node, out);
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function collectPageUrls(tree: unknown, out: Set<string> = new Set<string>()): Set<string> {
93
+ if (!tree || typeof tree !== 'object') return out;
94
+
95
+ const node = tree as {
96
+ type?: string;
97
+ url?: unknown;
98
+ external?: unknown;
99
+ index?: { url?: unknown };
100
+ children?: unknown[];
101
+ };
102
+
103
+ if (node.type === 'page' && node.external !== true && typeof node.url === 'string' && node.url.length > 0) {
104
+ out.add(normalizeSidebarTabUrl(node.url));
105
+ }
106
+
107
+ if (node.type === 'folder' && typeof node.index?.url === 'string' && node.index.url.length > 0) {
108
+ out.add(normalizeSidebarTabUrl(node.index.url));
109
+ }
110
+
111
+ if (Array.isArray(node.children)) {
112
+ for (const child of node.children) collectPageUrls(child, out);
113
+ }
114
+
115
+ return out;
116
+ }
117
+
118
+ function doesUrlBelongToTab(url: string, tabSlug: string): boolean {
119
+ const normalizedUrl = normalizePath(url);
120
+ const normalizedTab = normalizePath(tabSlug);
121
+ if (!normalizedUrl || !normalizedTab) return false;
122
+ return normalizedUrl === normalizedTab
123
+ || normalizedUrl.startsWith(`${normalizedTab}/`)
124
+ || normalizedUrl.includes(`/${normalizedTab}/`)
125
+ || normalizedUrl.endsWith(`/${normalizedTab}`);
126
+ }
127
+
128
+ // ── Locale/version/product/tab resolution ──────────────────────────────────
129
+
130
+ export function resolveLocale(slugInput: string[] | undefined, languages: string[]): string {
131
+ const defaultLanguage = languages[0] ?? 'en';
132
+ const slug = slugInput ?? [];
133
+ const firstSeg = slug[0];
134
+ return languages.includes(firstSeg ?? '') ? firstSeg! : defaultLanguage;
135
+ }
136
+
137
+ export function resolveCurrentVersion(slugInput: string[] | undefined, versions: VeluVersionOption[]): VeluVersionOption | undefined {
138
+ if (versions.length === 0) return undefined;
139
+ const firstSeg = (slugInput ?? [])[0] ?? '';
140
+ return versions.find((v) => v.slug === firstSeg) ?? versions.find((v) => v.isDefault) ?? versions[0];
141
+ }
142
+
143
+ export function resolveCurrentProduct(slugInput: string[] | undefined, products: VeluProductOption[]): VeluProductOption | undefined {
144
+ if (products.length === 0) return undefined;
145
+ const firstSeg = (slugInput ?? [])[0] ?? '';
146
+ return products.find((p) => p.slug === firstSeg) ?? products[0];
147
+ }
148
+
149
+ export function resolveTabContext(slugInput: string[] | undefined, languages: string[]): { containerSlug?: string; tabSlug?: string } {
150
+ const slug = slugInput ?? [];
151
+ const contentSlug = languages.includes(slug[0] ?? '') ? slug.slice(1) : slug;
152
+ if (contentSlug.length === 0) return {};
153
+ if (contentSlug.length > 1) {
154
+ return { containerSlug: contentSlug[0], tabSlug: contentSlug[1] };
155
+ }
156
+ return { tabSlug: contentSlug[0] };
157
+ }
158
+
159
+ // ── Tree filtering/scoping ─────────────────────────────────────────────────
160
+
161
+ export function filterTreeBySlugPrefix<T extends { children?: unknown[] }>(tree: T, prefix?: string): T {
162
+ if (!prefix) return tree;
163
+
164
+ const normPrefix = prefix.replace(/^\/+|\/+$/g, '').toLowerCase();
165
+ if (!normPrefix) return tree;
166
+
167
+ const matchesPrefix = (value: string): boolean => {
168
+ const norm = value.replace(/^\/+|\/+$/g, '').toLowerCase();
169
+ return norm === normPrefix || norm.startsWith(`${normPrefix}/`) || norm.includes(`/${normPrefix}/`) || norm.endsWith(`/${normPrefix}`);
170
+ };
171
+
172
+ const filterNodes = (nodes: unknown[]): unknown[] => {
173
+ const kept: unknown[] = [];
174
+
175
+ for (const node of nodes) {
176
+ if (typeof node !== 'object' || node === null) continue;
177
+ const entry = node as {
178
+ url?: unknown;
179
+ path?: unknown;
180
+ $ref?: { metaFile?: unknown; file?: unknown };
181
+ children?: unknown[];
182
+ };
183
+
184
+ const candidates = [entry.url, entry.path, entry.$ref?.metaFile, entry.$ref?.file]
185
+ .filter((value): value is string => typeof value === 'string');
186
+ const selfMatch = candidates.some(matchesPrefix);
187
+
188
+ const childNodes = Array.isArray(entry.children) ? entry.children : [];
189
+ const filteredChildren = childNodes.length > 0 ? filterNodes(childNodes) : [];
190
+ const childMatch = filteredChildren.length > 0;
191
+
192
+ if (selfMatch || childMatch) {
193
+ kept.push(childMatch ? { ...entry, children: filteredChildren } : entry);
194
+ }
195
+ }
196
+
197
+ return kept;
198
+ };
199
+
200
+ const children = Array.isArray(tree.children) ? tree.children : [];
201
+ const filtered = filterNodes(children);
202
+ if (filtered.length === 0) return tree;
203
+ return { ...tree, children: filtered } as T;
204
+ }
205
+
206
+ export function scopeTreeToTab<T extends { children?: unknown[] }>(
207
+ tree: T,
208
+ tabSlug?: string,
209
+ containerSlug?: string,
210
+ ): T {
211
+ const normalizedTab = (tabSlug ?? '').trim().toLowerCase();
212
+ if (!normalizedTab) return tree;
213
+
214
+ const topChildren = Array.isArray(tree.children) ? tree.children : [];
215
+ const rootFolders = topChildren.filter((child) => {
216
+ const node = child as PageTreeFolderNode;
217
+ return node?.type === 'folder' && node.root === true;
218
+ }) as PageTreeFolderNode[];
219
+
220
+ if (rootFolders.length > 1) {
221
+ const activeTopTab = (containerSlug ?? tabSlug ?? '').trim().toLowerCase();
222
+ if (!activeTopTab) return tree;
223
+
224
+ const matchedRoot = rootFolders.find((folder) => {
225
+ const urls = collectFolderUrls(folder);
226
+ for (const url of urls) {
227
+ const segments = url.split('/').filter(Boolean).map((segment) => segment.toLowerCase());
228
+ if ((segments[0] ?? '') === activeTopTab) return true;
229
+ }
230
+ return false;
231
+ });
232
+
233
+ if (!matchedRoot || !Array.isArray(matchedRoot.children)) return tree;
234
+ return { ...tree, children: matchedRoot.children } as T;
235
+ }
236
+
237
+ const rootFolder = topChildren.find((child) => {
238
+ const node = child as PageTreeFolderNode;
239
+ return node?.type === 'folder' && node.root === true;
240
+ }) as PageTreeFolderNode | undefined;
241
+
242
+ if (!rootFolder || !Array.isArray(rootFolder.children)) return tree;
243
+
244
+ const normalizedContainer = (containerSlug ?? '').trim().toLowerCase();
245
+ const matchingChildren = rootFolder.children.filter((child): child is PageTreeFolderNode => {
246
+ const folder = child as PageTreeFolderNode;
247
+ if (folder?.type !== 'folder') return false;
248
+
249
+ const urls = collectFolderUrls(folder);
250
+ for (const url of urls) {
251
+ const segments = url.split('/').filter(Boolean).map((segment) => segment.toLowerCase());
252
+ if (segments.length === 0) continue;
253
+
254
+ const tabCandidate = normalizedContainer && segments[0] === normalizedContainer
255
+ ? segments[1]
256
+ : segments[0];
257
+ if (tabCandidate === normalizedTab) return true;
258
+ }
259
+ return false;
260
+ });
261
+
262
+ if (matchingChildren.length === 0) return tree;
263
+
264
+ const firstMatch = matchingChildren[0];
265
+ const flattenedChildren = matchingChildren.length === 1 && Array.isArray(firstMatch?.children) && firstMatch.children.length > 0
266
+ ? firstMatch.children
267
+ : matchingChildren;
268
+
269
+ const scopedRoot = { ...rootFolder, children: flattenedChildren };
270
+ const scopedChildren = topChildren.map((child) => (child === rootFolder ? scopedRoot : child));
271
+ return { ...tree, children: scopedChildren } as T;
272
+ }
273
+
274
+ function flattenSingleRootFolder<T extends { children?: unknown[] }>(tree: T): T {
275
+ const topChildren = Array.isArray(tree.children) ? tree.children : [];
276
+ if (topChildren.length === 0) return tree;
277
+
278
+ const rootFolders = topChildren.filter((child) => {
279
+ const node = child as PageTreeFolderNode;
280
+ return node?.type === 'folder' && node.root === true;
281
+ }) as PageTreeFolderNode[];
282
+
283
+ if (rootFolders.length !== 1) return tree;
284
+ const rootFolder = rootFolders[0];
285
+ const rootChildren = Array.isArray(rootFolder.children) ? rootFolder.children : [];
286
+ if (rootChildren.length === 0) return tree;
287
+
288
+ const nonRootChildren = topChildren.filter((child) => child !== rootFolder);
289
+ return { ...tree, children: [...rootChildren, ...nonRootChildren] } as T;
290
+ }
291
+
292
+ function renderIconsInTree<T>(node: T, iconLibrary: VeluIconLibrary): T {
293
+ if (Array.isArray(node)) return node.map((item) => renderIconsInTree(item, iconLibrary)) as T;
294
+ if (isValidElement(node)) return node;
295
+ if (typeof node !== 'object' || node === null) return node;
296
+
297
+ const out: Record<string, unknown> = {};
298
+ const nodeWithIconType = node as { iconType?: unknown };
299
+ for (const [key, value] of Object.entries(node)) {
300
+ if (key === 'icon' && typeof value === 'string') {
301
+ const iconType = typeof nodeWithIconType.iconType === 'string'
302
+ ? nodeWithIconType.iconType
303
+ : undefined;
304
+ out[key] = <VeluIcon name={value} iconType={iconType} library={iconLibrary} fallback={false} />;
305
+ continue;
306
+ }
307
+ if (key === 'url' && typeof value === 'string') {
308
+ out[key] = withTrailingSlashUrl(value);
309
+ continue;
310
+ }
311
+ out[key] = renderIconsInTree(value, iconLibrary);
312
+ }
313
+ return out as T;
314
+ }
315
+
316
+ function buildNavbarTabs(tree: unknown): Array<{
317
+ url: string;
318
+ title: ReactNode;
319
+ icon?: ReactNode;
320
+ description?: ReactNode;
321
+ urls: Set<string>;
322
+ }> | undefined {
323
+ const rootChildren = Array.isArray((tree as { children?: unknown[] })?.children)
324
+ ? (tree as { children: unknown[] }).children
325
+ : [];
326
+
327
+ const rootFolders = rootChildren.filter((child) => {
328
+ const node = child as PageTreeFolderNode;
329
+ return node?.type === 'folder' && node.root === true;
330
+ }) as PageTreeFolderNode[];
331
+
332
+ const tabFolders: PageTreeFolderNode[] = rootFolders.length > 1
333
+ ? rootFolders
334
+ : (rootFolders.length === 1 && Array.isArray(rootFolders[0]?.children)
335
+ ? rootFolders[0].children.filter((child) => (child as PageTreeFolderNode)?.type === 'folder') as PageTreeFolderNode[]
336
+ : rootChildren.filter((child) => (child as PageTreeFolderNode)?.type === 'folder') as PageTreeFolderNode[]);
337
+
338
+ const tabs = tabFolders
339
+ .map((folder) => {
340
+ const urls = collectFolderUrls(folder);
341
+ const firstUrl = urls.values().next().value as string | undefined;
342
+ if (!firstUrl) return null;
343
+ return {
344
+ url: firstUrl,
345
+ title: folder.name ?? '',
346
+ icon: folder.icon,
347
+ description: folder.description,
348
+ urls,
349
+ };
350
+ })
351
+ .filter((tab): tab is NonNullable<typeof tab> => tab !== null);
352
+
353
+ return tabs.length > 0 ? tabs : undefined;
354
+ }
355
+
356
+ function resolveMenuTargetUrl(menuPages: string[], tabUrls: Set<string>): string | undefined {
357
+ const urls = Array.from(tabUrls);
358
+ if (urls.length === 0) return undefined;
359
+
360
+ for (const page of menuPages) {
361
+ const normalizedPage = normalizePath(page);
362
+ if (!normalizedPage) continue;
363
+
364
+ const direct = urls.find((url) => {
365
+ const normalizedUrl = normalizePath(url);
366
+ return normalizedUrl === normalizedPage || normalizedUrl.endsWith(`/${normalizedPage}`);
367
+ });
368
+ if (direct) return direct;
369
+
370
+ const pageBase = basename(normalizedPage);
371
+ const basenameMatches = urls.filter((url) => basename(url) === pageBase);
372
+ if (basenameMatches.length === 1) return basenameMatches[0];
373
+ }
374
+
375
+ return undefined;
376
+ }
377
+
378
+ function resolveMenuLinksForTab(
379
+ tabUrls: Set<string>,
380
+ candidates: VeluTabMenuDefinition[],
381
+ ): Array<{ text: string; url: string }> {
382
+ let best: Array<{ text: string; url: string }> = [];
383
+
384
+ for (const candidate of candidates) {
385
+ const resolved = candidate.items
386
+ .map((item) => {
387
+ const target = resolveMenuTargetUrl(item.pages, tabUrls);
388
+ if (!target) return null;
389
+ return { text: item.item, url: target };
390
+ })
391
+ .filter((entry): entry is { text: string; url: string } => entry !== null);
392
+
393
+ if (resolved.length > best.length) best = resolved;
394
+ }
395
+
396
+ return best;
397
+ }
398
+
399
+ function withPrefixedPath(url: string, prefix?: string): string {
400
+ const normalizedPrefix = (prefix ?? '').trim().replace(/^\/+|\/+$/g, '');
401
+ if (!normalizedPrefix) return url;
402
+ if (/^(https?:|mailto:|tel:|#)/i.test(url)) return url;
403
+
404
+ const hashIndex = url.indexOf('#');
405
+ const queryIndex = url.indexOf('?');
406
+ const endIndex = [hashIndex, queryIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0] ?? url.length;
407
+ const path = url.slice(0, endIndex);
408
+ const suffix = url.slice(endIndex);
409
+ if (!path.startsWith('/')) return url;
410
+
411
+ const prefixed = path === '/'
412
+ ? `/${normalizedPrefix}`
413
+ : path.startsWith(`/${normalizedPrefix}/`) || path === `/${normalizedPrefix}`
414
+ ? path
415
+ : `/${normalizedPrefix}${path}`;
416
+
417
+ return `${prefixed}${suffix}`;
418
+ }
419
+
420
+ function resolveRequestPathPrefix(
421
+ slugInput: string[] | undefined,
422
+ tabs: Array<{ url: string }>,
423
+ ): string | undefined {
424
+ const slug = (slugInput ?? []).map((segment) => segment.trim().toLowerCase()).filter(Boolean);
425
+ if (slug.length < 2) return undefined;
426
+
427
+ const tabRoots = new Set(
428
+ tabs
429
+ .map((tab) => normalizePath(tab.url).split('/').filter(Boolean)[0] ?? '')
430
+ .map((segment) => segment.toLowerCase())
431
+ .filter((segment) => segment.length > 0),
432
+ );
433
+
434
+ const first = slug[0] ?? '';
435
+ const second = slug[1] ?? '';
436
+ if (!first || !second) return undefined;
437
+ if (tabRoots.has(first)) return undefined;
438
+ if (tabRoots.has(second)) return first;
439
+ return undefined;
440
+ }
441
+
442
+ // ── URL prefixing for preview sessions ─────────────────────────────────────
443
+
444
+ function prefixTreeUrls(tree: any, prefix: string): any {
445
+ function walk(node: any): any {
446
+ if (!node || typeof node !== 'object') return node;
447
+ const copy = { ...node };
448
+ if (typeof copy.url === 'string' && copy.url.startsWith('/')) {
449
+ copy.url = `${prefix}${copy.url}`;
450
+ }
451
+ if (copy.index && typeof copy.index.url === 'string' && copy.index.url.startsWith('/')) {
452
+ copy.index = { ...copy.index, url: `${prefix}${copy.index.url}` };
453
+ }
454
+ if (Array.isArray(copy.children)) {
455
+ copy.children = copy.children.map(walk);
456
+ }
457
+ return copy;
458
+ }
459
+ return walk(tree);
460
+ }
461
+
462
+ // ── Shared rendering config ────────────────────────────────────────────────
463
+
464
+ export interface DocsLayoutConfig {
465
+ slug: string[] | undefined;
466
+ tree: any; // full locale page tree (before scoping)
467
+ languages: string[];
468
+ versions: VeluVersionOption[];
469
+ products: VeluProductOption[];
470
+ dropdowns: VeluDropdownOption[];
471
+ iconLibrary: VeluIconLibrary;
472
+ tabMenuDefinitions: VeluTabMenuDefinition[];
473
+ base: BaseLayoutProps;
474
+ globalAnchors: ReturnType<typeof import('@/lib/velu').getGlobalAnchors>;
475
+ appearance: 'system' | 'light' | 'dark';
476
+ /** Optional prefix to prepend to all rendered URLs (e.g. '/mint-test' for preview sessions) */
477
+ urlPrefix?: string;
478
+ }
479
+
480
+ export function renderDocsLayout(config: DocsLayoutConfig, children: ReactNode): JSX.Element {
481
+ const {
482
+ slug: slugInput,
483
+ tree: localePageTree,
484
+ languages,
485
+ versions,
486
+ products,
487
+ dropdowns,
488
+ iconLibrary,
489
+ tabMenuDefinitions,
490
+ base,
491
+ globalAnchors,
492
+ appearance,
493
+ urlPrefix,
494
+ } = config;
495
+
496
+ const pfx = (url: string) => urlPrefix ? `${urlPrefix}${url}` : url;
497
+
498
+ const currentVersion = resolveCurrentVersion(slugInput, versions);
499
+ const currentProduct = resolveCurrentProduct(slugInput, products);
500
+ const { containerSlug, tabSlug: currentTabSlug } = resolveTabContext(slugInput, languages);
501
+ const activePrefix = currentVersion?.slug ?? currentProduct?.slug;
502
+ const containerScopedTree = filterTreeBySlugPrefix(localePageTree, activePrefix);
503
+ const rawTree = scopeTreeToTab(containerScopedTree, currentTabSlug, containerSlug);
504
+ const activeTree = dropdowns.length > 0 ? flattenSingleRootFolder(rawTree) : rawTree;
505
+ const navbarTabs = buildNavbarTabs(localePageTree) ?? [];
506
+ const allPageUrls = collectPageUrls(localePageTree);
507
+ const requestPathPrefix = resolveRequestPathPrefix(slugInput, navbarTabs);
508
+ const resolvedTree = renderIconsInTree(activeTree, iconLibrary);
509
+ const tree = urlPrefix ? prefixTreeUrls(resolvedTree, urlPrefix) : resolvedTree;
510
+
511
+ const dropdownTabs = dropdowns.map((dropdown) => {
512
+ const defaultUrl = withTrailingSlashUrl(pfx(dropdown.defaultPath));
513
+ const matchingUrls = Array.from(allPageUrls).filter((url) => (
514
+ doesUrlBelongToTab(url, dropdown.slug)
515
+ || dropdown.tabSlugs.some((tabSlug) => doesUrlBelongToTab(url, tabSlug))
516
+ )).map(pfx);
517
+ const urls = new Set<string>(matchingUrls);
518
+ urls.add(normalizeSidebarTabUrl(defaultUrl));
519
+
520
+ return {
521
+ url: defaultUrl,
522
+ urls,
523
+ title: dropdown.dropdown,
524
+ description: dropdown.description,
525
+ icon: dropdown.icon ? (
526
+ <VeluIcon
527
+ name={dropdown.icon}
528
+ iconType={dropdown.iconType}
529
+ library={iconLibrary}
530
+ fallback={false}
531
+ />
532
+ ) : undefined,
533
+ };
534
+ });
535
+
536
+ const headerTabLinks: LinkItemType[] = navbarTabs
537
+ .map((tab): LinkItemType | null => {
538
+ const tabText = typeof tab.title === 'string' ? tab.title : '';
539
+ if (tabText.length === 0) return null;
540
+
541
+ const menuCandidates = tabMenuDefinitions.filter(
542
+ (definition) => definition.tab.trim().toLowerCase() === tabText.trim().toLowerCase(),
543
+ );
544
+ const menuLinks = resolveMenuLinksForTab(tab.urls, menuCandidates);
545
+
546
+ if (menuLinks.length > 0) {
547
+ return {
548
+ type: 'menu',
549
+ text: tabText,
550
+ url: pfx(withTrailingSlashUrl(withPrefixedPath(tab.url, requestPathPrefix))),
551
+ active: 'nested-url',
552
+ secondary: false,
553
+ items: menuLinks.map((item) => ({
554
+ text: item.text,
555
+ url: pfx(withTrailingSlashUrl(withPrefixedPath(item.url, requestPathPrefix))),
556
+ active: 'nested-url',
557
+ })),
558
+ };
559
+ }
560
+
561
+ return {
562
+ type: 'custom',
563
+ secondary: false,
564
+ children: (
565
+ <HeaderTabLink
566
+ text={tabText}
567
+ href={pfx(withTrailingSlashUrl(withPrefixedPath(tab.url, requestPathPrefix)))}
568
+ urls={Array.from(tab.urls).map((url) => pfx(withTrailingSlashUrl(withPrefixedPath(url, requestPathPrefix))))}
569
+ />
570
+ ),
571
+ };
572
+ })
573
+ .filter((link): link is LinkItemType => link !== null);
574
+
575
+ return (
576
+ <DocsLayout
577
+ tree={tree}
578
+ sidebar={{
579
+ tabs: dropdownTabs.length > 0 ? dropdownTabs : undefined,
580
+ collapsible: true,
581
+ banner: products.length > 1
582
+ ? (
583
+ <div className="velu-sidebar-banner">
584
+ <ProductSwitcher products={products} iconLibrary={iconLibrary} />
585
+ </div>
586
+ )
587
+ : undefined,
588
+ footer: ({ className, children: footerChildren, ...props }: any) => (
589
+ <div
590
+ className={['velu-sidebar-footer-shell', className].filter(Boolean).join(' ')}
591
+ {...props}
592
+ >
593
+ {footerChildren ? <div className="velu-sidebar-footer-icons">{footerChildren}</div> : null}
594
+ <SidebarLinks anchors={globalAnchors} iconLibrary={iconLibrary} languages={languages} />
595
+ </div>
596
+ ),
597
+ }}
598
+ {...base}
599
+ links={headerTabLinks.length > 0 ? headerTabLinks : base.links}
600
+ themeSwitch={{ enabled: false }}
601
+ >
602
+ {children}
603
+ </DocsLayout>
604
+ );
605
+ }
@@ -1,14 +1,14 @@
1
1
  import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
2
2
  import { createElement } from 'react';
3
3
  import { VersionSwitcher } from '@/components/version-switcher';
4
- import { getExternalTabs, getNavbarAnchors, getSiteLogoAsset, getSiteName, getVersionOptions } from '@/lib/velu';
4
+ import { getExternalTabs, getNavbarAnchors, getSiteLogoAsset, getSiteName, getVersionOptions, type VeluConfigSource } from '@/lib/velu';
5
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();
6
+ export function baseOptions(src?: VeluConfigSource): BaseLayoutProps {
7
+ const externalTabs = getExternalTabs(src);
8
+ const navAnchors = getNavbarAnchors(src);
9
+ const versions = getVersionOptions(src);
10
+ const siteName = getSiteName(src);
11
+ const logo = getSiteLogoAsset(src);
12
12
  const lightLogo = logo.light ?? logo.dark;
13
13
  const darkLogo = logo.dark ?? logo.light;
14
14
  const logoHref = typeof logo.href === 'string' && logo.href.trim().length > 0 ? logo.href.trim() : '/';