@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.
- package/package.json +1 -1
- package/src/build.ts +13 -0
- package/src/cli.ts +51 -9
- package/src/engine/app/(docs)/[...slug]/layout.tsx +21 -537
- package/src/engine/app/_preview/[sessionId]/[...slug]/layout.tsx +96 -0
- package/src/engine/app/_preview/[sessionId]/[...slug]/page.tsx +298 -0
- package/src/engine/app/_preview/[sessionId]/layout.tsx +56 -0
- package/src/{preview-engine/app → engine/app/_preview}/[sessionId]/page.tsx +7 -3
- package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/assets/[...path]/route.ts +1 -1
- package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/init/route.ts +2 -2
- package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/route.ts +3 -3
- package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/sync/route.ts +2 -2
- package/src/{preview-engine/app → engine/app/_preview}/layout.tsx +4 -1
- package/src/engine/app/global.css +0 -3623
- package/src/engine/app/layout.tsx +4 -3
- package/src/engine/components/sidebar-links.tsx +11 -5
- package/src/engine/lib/docs-layout.tsx +605 -0
- package/src/engine/lib/layout.shared.ts +7 -7
- package/src/engine/lib/preview-config.ts +129 -0
- package/src/{preview-engine/lib/content-generator.ts → engine/lib/preview-content.ts} +242 -42
- package/src/engine/lib/source.ts +80 -97
- package/src/engine/lib/velu.ts +79 -55
- package/src/engine/mdx-components.tsx +14 -650
- package/src/engine/source.config.ts +11 -89
- package/src/engine/tsconfig.json +1 -0
- package/src/engine-core/components/assistant.tsx +361 -0
- package/src/engine-core/components/banner.tsx +80 -0
- package/src/engine-core/components/changelog-filters.tsx +114 -0
- package/src/engine-core/components/code-group.tsx +383 -0
- package/src/engine-core/components/color.tsx +118 -0
- package/src/engine-core/components/copy-page.tsx +223 -0
- package/src/engine-core/components/dropdown-switcher.tsx +142 -0
- package/src/engine-core/components/expandable.tsx +77 -0
- package/src/engine-core/components/header-tab-link.tsx +43 -0
- package/src/engine-core/components/icon.tsx +136 -0
- package/src/engine-core/components/image-zoom-fallback.tsx +147 -0
- package/src/engine-core/components/image.tsx +111 -0
- package/src/engine-core/components/lang-switcher.tsx +101 -0
- package/src/engine-core/components/manual-api-playground.tsx +154 -0
- package/src/engine-core/components/mermaid.tsx +142 -0
- package/src/engine-core/components/openapi-toc-sync.tsx +59 -0
- package/src/engine-core/components/openapi.tsx +1682 -0
- package/src/engine-core/components/page-feedback-api.test.ts +83 -0
- package/src/engine-core/components/page-feedback-api.ts +89 -0
- package/src/engine-core/components/page-feedback.tsx +200 -0
- package/src/engine-core/components/product-switcher.tsx +107 -0
- package/src/engine-core/components/prompt.tsx +90 -0
- package/src/engine-core/components/providers.tsx +21 -0
- package/src/engine-core/components/search.tsx +318 -0
- package/src/engine-core/components/sidebar-links.tsx +54 -0
- package/src/engine-core/components/synced-tabs.tsx +57 -0
- package/src/engine-core/components/theme-toggle.tsx +39 -0
- package/src/engine-core/components/toc-examples.tsx +110 -0
- package/src/engine-core/components/version-switcher.tsx +95 -0
- package/src/engine-core/components/view.tsx +344 -0
- package/src/engine-core/css/assistant.css +326 -0
- package/src/engine-core/css/copy-page.css +206 -0
- package/src/engine-core/css/search.css +142 -0
- package/src/engine-core/css/shared.css +3628 -0
- package/src/engine-core/lib/remark-plugins.ts +102 -0
- package/src/engine-core/lib/source-plugins.ts +105 -0
- package/src/engine-core/mdx-components.tsx +654 -0
- package/src/engine-core/types.ts +49 -0
- package/src/preview-engine/app/[sessionId]/[...slug]/page.tsx +0 -41
- package/src/preview-engine/app/[sessionId]/layout.tsx +0 -26
- package/src/preview-engine/app/global.css +0 -29
- package/src/preview-engine/lib/session-config.ts +0 -86
- package/src/preview-engine/lib/session-layout.ts +0 -190
- package/src/preview-engine/lib/source.ts +0 -60
- package/src/preview-engine/next.config.mjs +0 -20
- package/src/preview-engine/postcss.config.mjs +0 -8
- package/src/preview-engine/source.config.ts +0 -26
- package/src/preview-engine/tsconfig.json +0 -32
- package/src/preview-engine/tsconfig.tsbuildinfo +0 -1
- /package/src/{preview-engine/app → engine/app/_preview}/page.tsx +0 -0
- /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 '
|
|
9
|
-
import '
|
|
10
|
-
import '
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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() : '/';
|