@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.
- package/package.json +1 -1
- package/schema/velu.schema.json +383 -122
- package/src/build.ts +679 -551
- package/src/cli.ts +157 -2
- package/src/engine/app/(docs)/[...slug]/layout.tsx +179 -13
- package/src/engine/app/(docs)/[...slug]/page.tsx +87 -12
- package/src/engine/app/copy-page.css +17 -1
- package/src/engine/app/global.css +111 -5
- package/src/engine/app/layout.tsx +8 -1
- package/src/engine/app/page.tsx +4 -1
- package/src/engine/app/search.css +4 -0
- package/src/engine/components/banner.tsx +80 -0
- package/src/engine/components/copy-page.tsx +162 -35
- package/src/engine/components/dropdown-switcher.tsx +142 -0
- package/src/engine/components/header-tab-link.tsx +43 -0
- package/src/engine/components/lang-switcher.tsx +7 -1
- package/src/engine/components/page-feedback.tsx +14 -3
- package/src/engine/components/product-switcher.tsx +8 -2
- package/src/engine/components/search.tsx +136 -49
- package/src/engine/components/version-switcher.tsx +8 -2
- package/src/engine/lib/layout.shared.ts +68 -68
- package/src/engine/lib/velu.ts +305 -2
- package/src/engine/next.config.mjs +4 -0
- package/src/validate.ts +8 -0
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolve, join, dirname, delimiter } from "node:path";
|
|
2
|
-
import { existsSync, mkdirSync, writeFileSync, readdirSync, copyFileSync, cpSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync, readdirSync, copyFileSync, cpSync, rmSync, renameSync, readFileSync } from "node:fs";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
@@ -214,7 +214,20 @@ function prepareRuntimeOutDir(docsOutDir: string): string {
|
|
|
214
214
|
const runtimeOutDir = join(PACKAGE_ROOT, ".velu-out");
|
|
215
215
|
if (samePath(docsOutDir, runtimeOutDir)) return runtimeOutDir;
|
|
216
216
|
|
|
217
|
-
|
|
217
|
+
try {
|
|
218
|
+
rmSync(runtimeOutDir, { recursive: true, force: true });
|
|
219
|
+
} catch {
|
|
220
|
+
// On Windows the directory may be locked by a previous dev-server process.
|
|
221
|
+
// Rename it aside so we can proceed; the stale copy is cleaned up later.
|
|
222
|
+
const stale = `${runtimeOutDir}-stale-${Date.now()}`;
|
|
223
|
+
try {
|
|
224
|
+
renameSync(runtimeOutDir, stale);
|
|
225
|
+
// Best-effort async cleanup — ignore errors if still locked.
|
|
226
|
+
try { rmSync(stale, { recursive: true, force: true }); } catch {}
|
|
227
|
+
} catch {
|
|
228
|
+
// If even rename fails, try to overwrite in place.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
218
231
|
cpSync(docsOutDir, runtimeOutDir, { recursive: true, force: true });
|
|
219
232
|
return runtimeOutDir;
|
|
220
233
|
}
|
|
@@ -261,11 +274,153 @@ function exportMarkdownRoutes(outDir: string) {
|
|
|
261
274
|
console.log(`📝 Exported ${copied} markdown files to static route paths`);
|
|
262
275
|
}
|
|
263
276
|
|
|
277
|
+
function collectStaticRoutePaths(distDir: string): string[] {
|
|
278
|
+
const routes = new Set<string>();
|
|
279
|
+
|
|
280
|
+
function walk(relDir: string) {
|
|
281
|
+
const absDir = join(distDir, relDir);
|
|
282
|
+
const entries = readdirSync(absDir, { withFileTypes: true });
|
|
283
|
+
|
|
284
|
+
for (const entry of entries) {
|
|
285
|
+
if (!entry.isDirectory()) continue;
|
|
286
|
+
const childRel = relDir ? join(relDir, entry.name) : entry.name;
|
|
287
|
+
if (existsSync(join(distDir, childRel, "index.html"))) {
|
|
288
|
+
const normalized = childRel.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
289
|
+
if (
|
|
290
|
+
normalized.length > 0 &&
|
|
291
|
+
!normalized.startsWith("_next") &&
|
|
292
|
+
!normalized.startsWith("_not-found") &&
|
|
293
|
+
normalized !== "404" &&
|
|
294
|
+
!normalized.startsWith("pagefind")
|
|
295
|
+
) {
|
|
296
|
+
routes.add(`/${normalized}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
walk(childRel);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
walk("");
|
|
304
|
+
return Array.from(routes).sort((a, b) => a.localeCompare(b));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function collectMarkdownPaths(distDir: string): string[] {
|
|
308
|
+
const markdownPaths = new Set<string>();
|
|
309
|
+
|
|
310
|
+
function walk(relDir: string) {
|
|
311
|
+
const absDir = join(distDir, relDir);
|
|
312
|
+
const entries = readdirSync(absDir, { withFileTypes: true });
|
|
313
|
+
for (const entry of entries) {
|
|
314
|
+
const relPath = relDir ? join(relDir, entry.name) : entry.name;
|
|
315
|
+
if (entry.isDirectory()) {
|
|
316
|
+
walk(relPath);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (!entry.isFile()) continue;
|
|
320
|
+
if (!entry.name.toLowerCase().endsWith(".md")) continue;
|
|
321
|
+
const normalized = relPath.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
322
|
+
markdownPaths.add(`/${normalized}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
walk("");
|
|
327
|
+
return Array.from(markdownPaths).sort((a, b) => a.localeCompare(b));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function addStaticRouteCompatibility(outDir: string) {
|
|
331
|
+
const distDir = join(outDir, "dist");
|
|
332
|
+
if (!existsSync(distDir)) return;
|
|
333
|
+
|
|
334
|
+
const routes = collectStaticRoutePaths(distDir);
|
|
335
|
+
if (routes.length === 0) return;
|
|
336
|
+
|
|
337
|
+
let aliasCount = 0;
|
|
338
|
+
for (const route of routes) {
|
|
339
|
+
const rel = route.replace(/^\/+/, "");
|
|
340
|
+
const src = join(distDir, rel, "index.html");
|
|
341
|
+
const htmlAlias = join(distDir, `${rel}.html`);
|
|
342
|
+
if (existsSync(src) && !existsSync(htmlAlias)) {
|
|
343
|
+
copyFileSync(src, htmlAlias);
|
|
344
|
+
aliasCount += 1;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const fallbackPath = join(distDir, "404.html");
|
|
349
|
+
if (existsSync(fallbackPath)) {
|
|
350
|
+
const html = readFileSync(fallbackPath, "utf-8");
|
|
351
|
+
if (!html.includes("velu-noslash-fallback")) {
|
|
352
|
+
const script = [
|
|
353
|
+
'<script id="velu-noslash-fallback">',
|
|
354
|
+
"(function(){",
|
|
355
|
+
" try {",
|
|
356
|
+
` var routes = new Set(${JSON.stringify(routes)});`,
|
|
357
|
+
" var path = (window.location && window.location.pathname ? window.location.pathname : '/').replace(/\\/+$/, '');",
|
|
358
|
+
" if (!path || path === '/') return;",
|
|
359
|
+
" if (/\\.[a-zA-Z0-9]+$/.test(path)) return;",
|
|
360
|
+
" if (!routes.has(path)) return;",
|
|
361
|
+
" var search = window.location.search || '';",
|
|
362
|
+
" var hash = window.location.hash || '';",
|
|
363
|
+
" window.location.replace(path + '/' + search + hash);",
|
|
364
|
+
" } catch (_) {}",
|
|
365
|
+
"})();",
|
|
366
|
+
"</script>",
|
|
367
|
+
].join("");
|
|
368
|
+
const patched = html.includes("</body>") ? html.replace("</body>", `${script}</body>`) : `${html}\n${script}\n`;
|
|
369
|
+
writeFileSync(fallbackPath, patched, "utf-8");
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const redirectsPath = join(distDir, "_redirects");
|
|
374
|
+
const existingRedirects = existsSync(redirectsPath) ? readFileSync(redirectsPath, "utf-8") : "";
|
|
375
|
+
const existingLines = new Set(existingRedirects.split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
|
|
376
|
+
const redirectLines = routes.map((route) => `${route} ${route}/ 301`);
|
|
377
|
+
let redirectAdded = 0;
|
|
378
|
+
for (const line of redirectLines) {
|
|
379
|
+
if (existingLines.has(line)) continue;
|
|
380
|
+
existingLines.add(line);
|
|
381
|
+
redirectAdded += 1;
|
|
382
|
+
}
|
|
383
|
+
if (redirectAdded > 0) {
|
|
384
|
+
const merged = Array.from(existingLines).join("\n") + "\n";
|
|
385
|
+
writeFileSync(redirectsPath, merged, "utf-8");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const mdPaths = collectMarkdownPaths(distDir);
|
|
389
|
+
if (mdPaths.length > 0) {
|
|
390
|
+
const headersPath = join(distDir, "_headers");
|
|
391
|
+
let mergedHeaders = existsSync(headersPath) ? readFileSync(headersPath, "utf-8") : "";
|
|
392
|
+
let headerAdded = 0;
|
|
393
|
+
|
|
394
|
+
for (const mdPath of mdPaths) {
|
|
395
|
+
const block = [
|
|
396
|
+
mdPath,
|
|
397
|
+
" Content-Type: text/markdown; charset=utf-8",
|
|
398
|
+
" Content-Disposition: inline",
|
|
399
|
+
" X-Content-Type-Options: nosniff",
|
|
400
|
+
"",
|
|
401
|
+
].join("\n");
|
|
402
|
+
if (mergedHeaders.includes(block)) continue;
|
|
403
|
+
if (mergedHeaders.length > 0 && !mergedHeaders.endsWith("\n")) mergedHeaders += "\n";
|
|
404
|
+
if (mergedHeaders.length > 0) mergedHeaders += "\n";
|
|
405
|
+
mergedHeaders += block;
|
|
406
|
+
headerAdded += 1;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (headerAdded > 0) {
|
|
410
|
+
writeFileSync(headersPath, mergedHeaders.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n", "utf-8");
|
|
411
|
+
}
|
|
412
|
+
console.log(`📄 Added inline markdown headers for ${mdPaths.length} .md routes`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
console.log(`🔁 Added static compatibility for ${routes.length} routes (${aliasCount} .html aliases, ${redirectAdded} redirects)`);
|
|
416
|
+
}
|
|
417
|
+
|
|
264
418
|
async function buildSite(docsDir: string) {
|
|
265
419
|
const docsOutDir = await generateProject(docsDir);
|
|
266
420
|
const runtimeOutDir = prepareRuntimeOutDir(docsOutDir);
|
|
267
421
|
await buildStatic(runtimeOutDir, docsDir);
|
|
268
422
|
exportMarkdownRoutes(runtimeOutDir);
|
|
423
|
+
addStaticRouteCompatibility(runtimeOutDir);
|
|
269
424
|
|
|
270
425
|
if (!samePath(docsOutDir, runtimeOutDir)) {
|
|
271
426
|
const docsDistDir = join(docsOutDir, "dist");
|
|
@@ -4,6 +4,7 @@ import type { LinkItemType } from 'fumadocs-ui/layouts/shared';
|
|
|
4
4
|
import { baseOptions } from '@/lib/layout.shared';
|
|
5
5
|
import { source } from '@/lib/source';
|
|
6
6
|
import {
|
|
7
|
+
getDropdownOptions,
|
|
7
8
|
getIconLibrary,
|
|
8
9
|
getLanguages,
|
|
9
10
|
getVersionOptions,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
import { SidebarLinks } from '@/components/sidebar-links';
|
|
16
17
|
import { ProductSwitcher } from '@/components/product-switcher';
|
|
17
18
|
import { VeluIcon } from '@/components/icon';
|
|
19
|
+
import { HeaderTabLink } from '@/components/header-tab-link';
|
|
18
20
|
|
|
19
21
|
interface LayoutParams {
|
|
20
22
|
slug?: string[];
|
|
@@ -41,6 +43,26 @@ interface PageTreeFolderNode {
|
|
|
41
43
|
children?: unknown[];
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
function withTrailingSlashUrl(url: string): string {
|
|
47
|
+
const trimmed = url.trim();
|
|
48
|
+
if (trimmed.length === 0) return trimmed;
|
|
49
|
+
if (/^(https?:|mailto:|tel:|#)/i.test(trimmed)) return trimmed;
|
|
50
|
+
|
|
51
|
+
const hashIndex = trimmed.indexOf('#');
|
|
52
|
+
const queryIndex = trimmed.indexOf('?');
|
|
53
|
+
const endIndex = [hashIndex, queryIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0] ?? trimmed.length;
|
|
54
|
+
const path = trimmed.slice(0, endIndex);
|
|
55
|
+
const suffix = trimmed.slice(endIndex);
|
|
56
|
+
|
|
57
|
+
if (!path.startsWith('/')) return trimmed;
|
|
58
|
+
if (path === '/' || path.endsWith('/')) return `${path}${suffix}`;
|
|
59
|
+
|
|
60
|
+
const lastSegment = path.split('/').filter(Boolean).pop() ?? '';
|
|
61
|
+
if (lastSegment.includes('.')) return trimmed;
|
|
62
|
+
|
|
63
|
+
return `${path}/${suffix}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
44
66
|
function resolveLocale(slugInput: string[] | undefined): string {
|
|
45
67
|
const languages = getLanguages();
|
|
46
68
|
const defaultLanguage = languages[0] ?? 'en';
|
|
@@ -122,6 +144,10 @@ function renderIconsInTree<T>(node: T, iconLibrary: 'fontawesome' | 'lucide' | '
|
|
|
122
144
|
out[key] = <VeluIcon name={value} iconType={iconType} library={iconLibrary} fallback={false} />;
|
|
123
145
|
continue;
|
|
124
146
|
}
|
|
147
|
+
if (key === 'url' && typeof value === 'string') {
|
|
148
|
+
out[key] = withTrailingSlashUrl(value);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
125
151
|
out[key] = renderIconsInTree(value, iconLibrary);
|
|
126
152
|
}
|
|
127
153
|
return out as T;
|
|
@@ -198,12 +224,54 @@ function normalizePath(value: string): string {
|
|
|
198
224
|
return value.replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
199
225
|
}
|
|
200
226
|
|
|
227
|
+
function normalizeSidebarTabUrl(url: string): string {
|
|
228
|
+
const trimmed = url.trim();
|
|
229
|
+
if (trimmed.length <= 1) return trimmed;
|
|
230
|
+
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed;
|
|
231
|
+
}
|
|
232
|
+
|
|
201
233
|
function basename(value: string): string {
|
|
202
234
|
const normalized = normalizePath(value);
|
|
203
235
|
const parts = normalized.split('/').filter(Boolean);
|
|
204
236
|
return parts[parts.length - 1] ?? normalized;
|
|
205
237
|
}
|
|
206
238
|
|
|
239
|
+
function collectPageUrls(tree: unknown, out: Set<string> = new Set<string>()): Set<string> {
|
|
240
|
+
if (!tree || typeof tree !== 'object') return out;
|
|
241
|
+
|
|
242
|
+
const node = tree as {
|
|
243
|
+
type?: string;
|
|
244
|
+
url?: unknown;
|
|
245
|
+
external?: unknown;
|
|
246
|
+
index?: { url?: unknown };
|
|
247
|
+
children?: unknown[];
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
if (node.type === 'page' && node.external !== true && typeof node.url === 'string' && node.url.length > 0) {
|
|
251
|
+
out.add(normalizeSidebarTabUrl(node.url));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (node.type === 'folder' && typeof node.index?.url === 'string' && node.index.url.length > 0) {
|
|
255
|
+
out.add(normalizeSidebarTabUrl(node.index.url));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (Array.isArray(node.children)) {
|
|
259
|
+
for (const child of node.children) collectPageUrls(child, out);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function doesUrlBelongToTab(url: string, tabSlug: string): boolean {
|
|
266
|
+
const normalizedUrl = normalizePath(url);
|
|
267
|
+
const normalizedTab = normalizePath(tabSlug);
|
|
268
|
+
if (!normalizedUrl || !normalizedTab) return false;
|
|
269
|
+
return normalizedUrl === normalizedTab
|
|
270
|
+
|| normalizedUrl.startsWith(`${normalizedTab}/`)
|
|
271
|
+
|| normalizedUrl.includes(`/${normalizedTab}/`)
|
|
272
|
+
|| normalizedUrl.endsWith(`/${normalizedTab}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
207
275
|
function resolveMenuTargetUrl(menuPages: string[], tabUrls: Set<string>): string | undefined {
|
|
208
276
|
const urls = Array.from(tabUrls);
|
|
209
277
|
if (urls.length === 0) return undefined;
|
|
@@ -247,6 +315,49 @@ function resolveMenuLinksForTab(
|
|
|
247
315
|
return best;
|
|
248
316
|
}
|
|
249
317
|
|
|
318
|
+
function withPrefixedPath(url: string, prefix?: string): string {
|
|
319
|
+
const normalizedPrefix = (prefix ?? '').trim().replace(/^\/+|\/+$/g, '');
|
|
320
|
+
if (!normalizedPrefix) return url;
|
|
321
|
+
if (/^(https?:|mailto:|tel:|#)/i.test(url)) return url;
|
|
322
|
+
|
|
323
|
+
const hashIndex = url.indexOf('#');
|
|
324
|
+
const queryIndex = url.indexOf('?');
|
|
325
|
+
const endIndex = [hashIndex, queryIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0] ?? url.length;
|
|
326
|
+
const path = url.slice(0, endIndex);
|
|
327
|
+
const suffix = url.slice(endIndex);
|
|
328
|
+
if (!path.startsWith('/')) return url;
|
|
329
|
+
|
|
330
|
+
const prefixed = path === '/'
|
|
331
|
+
? `/${normalizedPrefix}`
|
|
332
|
+
: path.startsWith(`/${normalizedPrefix}/`) || path === `/${normalizedPrefix}`
|
|
333
|
+
? path
|
|
334
|
+
: `/${normalizedPrefix}${path}`;
|
|
335
|
+
|
|
336
|
+
return `${prefixed}${suffix}`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function resolveRequestPathPrefix(
|
|
340
|
+
slugInput: string[] | undefined,
|
|
341
|
+
tabs: Array<{ url: string }>,
|
|
342
|
+
): string | undefined {
|
|
343
|
+
const slug = (slugInput ?? []).map((segment) => segment.trim().toLowerCase()).filter(Boolean);
|
|
344
|
+
if (slug.length < 2) return undefined;
|
|
345
|
+
|
|
346
|
+
const tabRoots = new Set(
|
|
347
|
+
tabs
|
|
348
|
+
.map((tab) => normalizePath(tab.url).split('/').filter(Boolean)[0] ?? '')
|
|
349
|
+
.map((segment) => segment.toLowerCase())
|
|
350
|
+
.filter((segment) => segment.length > 0),
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
const first = slug[0] ?? '';
|
|
354
|
+
const second = slug[1] ?? '';
|
|
355
|
+
if (!first || !second) return undefined;
|
|
356
|
+
if (tabRoots.has(first)) return undefined;
|
|
357
|
+
if (tabRoots.has(second)) return first;
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
250
361
|
function scopeTreeToTab<T extends { children?: unknown[] }>(
|
|
251
362
|
tree: T,
|
|
252
363
|
tabSlug?: string,
|
|
@@ -317,22 +428,69 @@ function scopeTreeToTab<T extends { children?: unknown[] }>(
|
|
|
317
428
|
return { ...tree, children: scopedChildren } as T;
|
|
318
429
|
}
|
|
319
430
|
|
|
431
|
+
function flattenSingleRootFolder<T extends { children?: unknown[] }>(tree: T): T {
|
|
432
|
+
const topChildren = Array.isArray(tree.children) ? tree.children : [];
|
|
433
|
+
if (topChildren.length === 0) return tree;
|
|
434
|
+
|
|
435
|
+
const rootFolders = topChildren.filter((child) => {
|
|
436
|
+
const node = child as PageTreeFolderNode;
|
|
437
|
+
return node?.type === 'folder' && node.root === true;
|
|
438
|
+
}) as PageTreeFolderNode[];
|
|
439
|
+
|
|
440
|
+
if (rootFolders.length !== 1) return tree;
|
|
441
|
+
const rootFolder = rootFolders[0];
|
|
442
|
+
const rootChildren = Array.isArray(rootFolder.children) ? rootFolder.children : [];
|
|
443
|
+
if (rootChildren.length === 0) return tree;
|
|
444
|
+
|
|
445
|
+
const nonRootChildren = topChildren.filter((child) => child !== rootFolder);
|
|
446
|
+
return { ...tree, children: [...rootChildren, ...nonRootChildren] } as T;
|
|
447
|
+
}
|
|
448
|
+
|
|
320
449
|
export default async function SlugLayout({ children, params }: SlugLayoutProps) {
|
|
321
450
|
const resolvedParams = await params;
|
|
322
451
|
const locale = resolveLocale(resolvedParams.slug);
|
|
452
|
+
const localePageTree = source.getPageTree(locale);
|
|
323
453
|
const versions = getVersionOptions();
|
|
324
454
|
const products = getProductOptions();
|
|
455
|
+
const dropdowns = getDropdownOptions();
|
|
325
456
|
const iconLibrary = getIconLibrary();
|
|
326
457
|
const currentVersion = resolveCurrentVersion(resolvedParams.slug, versions);
|
|
327
458
|
const currentProduct = resolveCurrentProduct(resolvedParams.slug, products);
|
|
328
459
|
const { containerSlug, tabSlug: currentTabSlug } = resolveTabContext(resolvedParams.slug);
|
|
329
460
|
const activePrefix = currentVersion?.slug ?? currentProduct?.slug;
|
|
330
|
-
const containerScopedTree = filterTreeBySlugPrefix(
|
|
461
|
+
const containerScopedTree = filterTreeBySlugPrefix(localePageTree, activePrefix);
|
|
331
462
|
const rawTree = scopeTreeToTab(containerScopedTree, currentTabSlug, containerSlug);
|
|
332
|
-
const
|
|
463
|
+
const activeTree = dropdowns.length > 0 ? flattenSingleRootFolder(rawTree) : rawTree;
|
|
464
|
+
const navbarTabs = buildNavbarTabs(localePageTree) ?? [];
|
|
465
|
+
const allPageUrls = collectPageUrls(localePageTree);
|
|
466
|
+
const requestPathPrefix = resolveRequestPathPrefix(resolvedParams.slug, navbarTabs);
|
|
333
467
|
const tabMenuDefinitions = getTabMenuDefinitions();
|
|
334
|
-
const tree = renderIconsInTree(
|
|
468
|
+
const tree = renderIconsInTree(activeTree, iconLibrary);
|
|
335
469
|
const base = baseOptions();
|
|
470
|
+
const dropdownTabs = dropdowns.map((dropdown) => {
|
|
471
|
+
const defaultUrl = withTrailingSlashUrl(dropdown.defaultPath);
|
|
472
|
+
const matchingUrls = Array.from(allPageUrls).filter((url) => (
|
|
473
|
+
doesUrlBelongToTab(url, dropdown.slug)
|
|
474
|
+
|| dropdown.tabSlugs.some((tabSlug) => doesUrlBelongToTab(url, tabSlug))
|
|
475
|
+
));
|
|
476
|
+
const urls = new Set<string>(matchingUrls);
|
|
477
|
+
urls.add(normalizeSidebarTabUrl(defaultUrl));
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
url: defaultUrl,
|
|
481
|
+
urls,
|
|
482
|
+
title: dropdown.dropdown,
|
|
483
|
+
description: dropdown.description,
|
|
484
|
+
icon: dropdown.icon ? (
|
|
485
|
+
<VeluIcon
|
|
486
|
+
name={dropdown.icon}
|
|
487
|
+
iconType={dropdown.iconType}
|
|
488
|
+
library={iconLibrary}
|
|
489
|
+
fallback={false}
|
|
490
|
+
/>
|
|
491
|
+
) : undefined,
|
|
492
|
+
};
|
|
493
|
+
});
|
|
336
494
|
const headerTabLinks: LinkItemType[] = navbarTabs
|
|
337
495
|
.map((tab): LinkItemType | null => {
|
|
338
496
|
const tabText = typeof tab.title === 'string' ? tab.title : '';
|
|
@@ -347,22 +505,27 @@ export default async function SlugLayout({ children, params }: SlugLayoutProps)
|
|
|
347
505
|
return {
|
|
348
506
|
type: 'menu',
|
|
349
507
|
text: tabText,
|
|
350
|
-
url: tab.url,
|
|
508
|
+
url: withTrailingSlashUrl(withPrefixedPath(tab.url, requestPathPrefix)),
|
|
351
509
|
active: 'nested-url',
|
|
352
510
|
secondary: false,
|
|
353
511
|
items: menuLinks.map((item) => ({
|
|
354
512
|
text: item.text,
|
|
355
|
-
url: item.url,
|
|
513
|
+
url: withTrailingSlashUrl(withPrefixedPath(item.url, requestPathPrefix)),
|
|
356
514
|
active: 'nested-url',
|
|
357
515
|
})),
|
|
358
516
|
};
|
|
359
517
|
}
|
|
360
518
|
|
|
361
519
|
return {
|
|
362
|
-
|
|
363
|
-
url: tab.url,
|
|
364
|
-
active: 'nested-url',
|
|
520
|
+
type: 'custom',
|
|
365
521
|
secondary: false,
|
|
522
|
+
children: (
|
|
523
|
+
<HeaderTabLink
|
|
524
|
+
text={tabText}
|
|
525
|
+
href={withTrailingSlashUrl(withPrefixedPath(tab.url, requestPathPrefix))}
|
|
526
|
+
urls={Array.from(tab.urls).map((url) => withTrailingSlashUrl(withPrefixedPath(url, requestPathPrefix)))}
|
|
527
|
+
/>
|
|
528
|
+
),
|
|
366
529
|
};
|
|
367
530
|
})
|
|
368
531
|
.filter((link): link is LinkItemType => link !== null);
|
|
@@ -371,12 +534,15 @@ export default async function SlugLayout({ children, params }: SlugLayoutProps)
|
|
|
371
534
|
<DocsLayout
|
|
372
535
|
tree={tree}
|
|
373
536
|
sidebar={{
|
|
537
|
+
tabs: dropdownTabs.length > 0 ? dropdownTabs : undefined,
|
|
374
538
|
collapsible: true,
|
|
375
|
-
banner: products.length > 1
|
|
376
|
-
|
|
377
|
-
<
|
|
378
|
-
|
|
379
|
-
|
|
539
|
+
banner: products.length > 1
|
|
540
|
+
? (
|
|
541
|
+
<div className="velu-sidebar-banner">
|
|
542
|
+
<ProductSwitcher products={products} iconLibrary={iconLibrary} />
|
|
543
|
+
</div>
|
|
544
|
+
)
|
|
545
|
+
: undefined,
|
|
380
546
|
footer: ({ className, children, ...props }: any) => (
|
|
381
547
|
<div
|
|
382
548
|
className={['velu-sidebar-footer-shell', className].filter(Boolean).join(' ')}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import { notFound } from 'next/navigation';
|
|
3
|
-
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { parse as parseYaml } from 'yaml';
|
|
6
6
|
import { createRelativeLink } from 'fumadocs-ui/mdx';
|
|
@@ -14,13 +14,26 @@ import { getMDXComponents } from '@/mdx-components';
|
|
|
14
14
|
import { source } from '@/lib/source';
|
|
15
15
|
import { VeluManualApiPlayground } from '@/components/manual-api-playground';
|
|
16
16
|
import { VeluOpenAPI, VeluOpenAPISchema } from '@/components/openapi';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
getApiConfig,
|
|
19
|
+
getContextualOptions,
|
|
20
|
+
getFooterSocials,
|
|
21
|
+
getLanguages,
|
|
22
|
+
getMetadataConfig,
|
|
23
|
+
getVersionOptions,
|
|
24
|
+
getProductOptions,
|
|
25
|
+
getSeoConfig,
|
|
26
|
+
getSiteDescription,
|
|
27
|
+
getSiteName,
|
|
28
|
+
getSiteOrigin,
|
|
29
|
+
} from '@/lib/velu';
|
|
18
30
|
import { CopyPageButton } from '@/components/copy-page';
|
|
19
31
|
import { ChangelogFilters } from '@/components/changelog-filters';
|
|
20
32
|
import { VeluImageZoomFallback } from '@/components/image-zoom-fallback';
|
|
21
33
|
import { OpenApiTocSync } from '@/components/openapi-toc-sync';
|
|
22
34
|
import { TocExamples } from '@/components/toc-examples';
|
|
23
35
|
import { PageFeedback } from '@/components/page-feedback';
|
|
36
|
+
import { VeluIcon } from '@/components/icon';
|
|
24
37
|
import { parseChangelogFromMarkdown, parseFrontmatterBoolean } from '@/lib/changelog';
|
|
25
38
|
|
|
26
39
|
interface RouteParams {
|
|
@@ -58,7 +71,12 @@ interface InlineApiDoc {
|
|
|
58
71
|
method: string;
|
|
59
72
|
}
|
|
60
73
|
|
|
61
|
-
|
|
74
|
+
interface LoadedMarkdown {
|
|
75
|
+
content: string;
|
|
76
|
+
modifiedAt?: Date;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function loadMarkdownForSlug(slug: string[], locale: string, hasI18n: boolean): Promise<LoadedMarkdown | undefined> {
|
|
62
80
|
const rel = slug.join('/');
|
|
63
81
|
const docsRoots = [
|
|
64
82
|
join(process.cwd(), 'content', 'docs'),
|
|
@@ -71,7 +89,12 @@ async function loadMarkdownForSlug(slug: string[], locale: string, hasI18n: bool
|
|
|
71
89
|
|
|
72
90
|
for (const filePath of paths) {
|
|
73
91
|
try {
|
|
74
|
-
|
|
92
|
+
const content = await readFile(filePath, 'utf-8');
|
|
93
|
+
const stats = await stat(filePath).catch(() => null);
|
|
94
|
+
return {
|
|
95
|
+
content,
|
|
96
|
+
modifiedAt: stats?.mtime,
|
|
97
|
+
};
|
|
75
98
|
} catch {
|
|
76
99
|
// ignore and continue
|
|
77
100
|
}
|
|
@@ -80,6 +103,14 @@ async function loadMarkdownForSlug(slug: string[], locale: string, hasI18n: bool
|
|
|
80
103
|
return undefined;
|
|
81
104
|
}
|
|
82
105
|
|
|
106
|
+
function formatLastModifiedDate(value: Date): string {
|
|
107
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
108
|
+
month: 'long',
|
|
109
|
+
day: 'numeric',
|
|
110
|
+
year: 'numeric',
|
|
111
|
+
}).format(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
83
114
|
function resolveLocaleSlug(slugInput: string[] | undefined) {
|
|
84
115
|
const languages = getLanguages();
|
|
85
116
|
const defaultLanguage = languages[0] ?? 'en';
|
|
@@ -215,6 +246,13 @@ function normalizeDocPath(value: string): string {
|
|
|
215
246
|
return collapsed;
|
|
216
247
|
}
|
|
217
248
|
|
|
249
|
+
function withTrailingSlashPath(path: string): string {
|
|
250
|
+
if (!path.startsWith('/')) return path;
|
|
251
|
+
if (path === '/' || path.endsWith('/')) return path;
|
|
252
|
+
if (/\.[a-zA-Z0-9]+$/.test(path)) return path;
|
|
253
|
+
return `${path}/`;
|
|
254
|
+
}
|
|
255
|
+
|
|
218
256
|
function toAbsoluteMetaUrl(origin: string, value: string): string {
|
|
219
257
|
const trimmed = value.trim();
|
|
220
258
|
if (!trimmed) return trimmed;
|
|
@@ -548,9 +586,11 @@ function buildInlineApiDoc(
|
|
|
548
586
|
|
|
549
587
|
export default async function Page({ params }: PageProps) {
|
|
550
588
|
const resolvedParams = await params;
|
|
589
|
+
const metadataConfig = getMetadataConfig();
|
|
551
590
|
const { locale, pageSlug } = resolveLocaleSlug(resolvedParams.slug);
|
|
552
591
|
const { locale: filterLocale, version, product } = resolveContextFromSlug(resolvedParams.slug);
|
|
553
592
|
const hasI18n = getLanguages().length > 1;
|
|
593
|
+
const footerSocials = getFooterSocials();
|
|
554
594
|
|
|
555
595
|
const page = hasI18n ? source.getPage(pageSlug, locale) : source.getPage(pageSlug);
|
|
556
596
|
|
|
@@ -559,7 +599,11 @@ export default async function Page({ params }: PageProps) {
|
|
|
559
599
|
const pageDataRecord = (page.data as unknown) as Record<string, unknown>;
|
|
560
600
|
const MDX = pageDataRecord.body as any;
|
|
561
601
|
if (typeof MDX !== 'function') notFound();
|
|
562
|
-
const
|
|
602
|
+
const loadedMarkdown = await loadMarkdownForSlug(pageSlug, locale, hasI18n);
|
|
603
|
+
const sourceMarkdown = loadedMarkdown?.content;
|
|
604
|
+
const lastModifiedLabel = metadataConfig.timestamp && loadedMarkdown?.modifiedAt
|
|
605
|
+
? formatLastModifiedDate(loadedMarkdown.modifiedAt)
|
|
606
|
+
: undefined;
|
|
563
607
|
const dataMarkdown = typeof pageDataRecord.processedMarkdown === 'string'
|
|
564
608
|
? String(pageDataRecord.processedMarkdown)
|
|
565
609
|
: undefined;
|
|
@@ -614,7 +658,7 @@ export default async function Page({ params }: PageProps) {
|
|
|
614
658
|
const pageUrl = (typeof sourcePageUrl === 'string' && sourcePageUrl.trim())
|
|
615
659
|
? sourcePageUrl
|
|
616
660
|
: (fallbackPath === '' ? '/' : fallbackPath);
|
|
617
|
-
const rssHref = `${pageUrl.replace(/\/$/, '') || ''}/rss.xml`;
|
|
661
|
+
const rssHref = `${withTrailingSlashPath(pageUrl).replace(/\/$/, '') || ''}/rss.xml`;
|
|
618
662
|
const shouldReplaceTocWithApiExample = !hasExplicitApiRendering && Boolean(inlineApiDoc) && playgroundDisplay === 'interactive';
|
|
619
663
|
const shouldShowOpenApiExampleInToc = !hasExplicitApiRendering && !parsedApiFrontmatter && Boolean(parsedOpenApiFrontmatter);
|
|
620
664
|
const hasApiTocRail = shouldReplaceTocWithApiExample || shouldShowOpenApiExampleInToc;
|
|
@@ -673,7 +717,7 @@ export default async function Page({ params }: PageProps) {
|
|
|
673
717
|
{isDeprecatedPage ? <span className="velu-pill velu-pill-deprecated velu-page-deprecated-badge">Deprecated</span> : null}
|
|
674
718
|
</div>
|
|
675
719
|
<div className="velu-title-actions">
|
|
676
|
-
<CopyPageButton />
|
|
720
|
+
<CopyPageButton options={getContextualOptions()} mcpUrl={getSiteOrigin() + '/mcp'} />
|
|
677
721
|
{showRssButton ? (
|
|
678
722
|
<a className="velu-rss-button" href={rssHref} aria-label="Subscribe to this changelog RSS feed">
|
|
679
723
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
@@ -686,6 +730,9 @@ export default async function Page({ params }: PageProps) {
|
|
|
686
730
|
</div>
|
|
687
731
|
</div>
|
|
688
732
|
{page.data.description ? <DocsDescription>{page.data.description}</DocsDescription> : null}
|
|
733
|
+
{lastModifiedLabel ? (
|
|
734
|
+
<p className="velu-page-last-updated">Last updated {lastModifiedLabel}</p>
|
|
735
|
+
) : null}
|
|
689
736
|
<DocsBody>
|
|
690
737
|
{!hasExplicitApiRendering && inlineApiDoc && playgroundDisplay === 'interactive' ? (
|
|
691
738
|
<VeluOpenAPI
|
|
@@ -741,7 +788,7 @@ export default async function Page({ params }: PageProps) {
|
|
|
741
788
|
{(previousPage || nextPage) ? (
|
|
742
789
|
<div className={['velu-page-nav-grid', previousPage && nextPage ? 'velu-page-nav-grid-two' : 'velu-page-nav-grid-one'].join(' ')}>
|
|
743
790
|
{previousPage ? (
|
|
744
|
-
<a href={previousPage.url} className="velu-page-nav-card">
|
|
791
|
+
<a href={withTrailingSlashPath(previousPage.url)} className="velu-page-nav-card">
|
|
745
792
|
<p className="velu-page-nav-title">{previousPage.data.title}</p>
|
|
746
793
|
<p className="velu-page-nav-meta">
|
|
747
794
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg>
|
|
@@ -750,7 +797,7 @@ export default async function Page({ params }: PageProps) {
|
|
|
750
797
|
</a>
|
|
751
798
|
) : null}
|
|
752
799
|
{nextPage ? (
|
|
753
|
-
<a href={nextPage.url} className="velu-page-nav-card velu-page-nav-card-next">
|
|
800
|
+
<a href={withTrailingSlashPath(nextPage.url)} className="velu-page-nav-card velu-page-nav-card-next">
|
|
754
801
|
<p className="velu-page-nav-title">{nextPage.data.title}</p>
|
|
755
802
|
<p className="velu-page-nav-meta velu-page-nav-meta-next">
|
|
756
803
|
<span>{nextPage.data.description ?? 'Next'}</span>
|
|
@@ -763,7 +810,34 @@ export default async function Page({ params }: PageProps) {
|
|
|
763
810
|
</section>
|
|
764
811
|
</div>
|
|
765
812
|
<footer className="velu-footer">
|
|
766
|
-
|
|
813
|
+
{footerSocials.length > 0 ? (
|
|
814
|
+
<div className="velu-footer-socials" aria-label="Social links">
|
|
815
|
+
{footerSocials.map((social) => (
|
|
816
|
+
<a
|
|
817
|
+
key={`${social.key}:${social.href}`}
|
|
818
|
+
href={social.href}
|
|
819
|
+
target="_blank"
|
|
820
|
+
rel="noopener noreferrer"
|
|
821
|
+
className="velu-footer-social-link"
|
|
822
|
+
aria-label={social.label}
|
|
823
|
+
title={social.label}
|
|
824
|
+
>
|
|
825
|
+
<VeluIcon
|
|
826
|
+
name={social.icon}
|
|
827
|
+
iconType={social.iconType}
|
|
828
|
+
library="fontawesome"
|
|
829
|
+
className="velu-footer-social-icon"
|
|
830
|
+
fallback={false}
|
|
831
|
+
/>
|
|
832
|
+
</a>
|
|
833
|
+
))}
|
|
834
|
+
</div>
|
|
835
|
+
) : (
|
|
836
|
+
<span />
|
|
837
|
+
)}
|
|
838
|
+
<div className="velu-footer-powered">
|
|
839
|
+
Powered by <a href="https://getvelu.com" target="_blank" rel="noopener noreferrer">Velu</a>
|
|
840
|
+
</div>
|
|
767
841
|
</footer>
|
|
768
842
|
</DocsPage>
|
|
769
843
|
);
|
|
@@ -797,7 +871,8 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
797
871
|
|
|
798
872
|
if (!page) notFound();
|
|
799
873
|
|
|
800
|
-
const
|
|
874
|
+
const loadedMarkdown = await loadMarkdownForSlug(pageSlug, locale, hasI18n);
|
|
875
|
+
const sourceMarkdown = loadedMarkdown?.content;
|
|
801
876
|
const pageDataRecord = (page.data as unknown) as Record<string, unknown>;
|
|
802
877
|
const dataMarkdown = typeof pageDataRecord.processedMarkdown === 'string'
|
|
803
878
|
? String(pageDataRecord.processedMarkdown)
|
|
@@ -828,7 +903,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
828
903
|
|| (mergedMetatags.robots ?? '').toLowerCase().includes('none');
|
|
829
904
|
const titleOverride = mergedMetatags.title?.trim();
|
|
830
905
|
const resolvedTitle = titleOverride || `${page.data.title} - ${siteName}`;
|
|
831
|
-
const resolvedDescription = (mergedMetatags.description?.trim() || page.data.description || '').trim() || undefined;
|
|
906
|
+
const resolvedDescription = (mergedMetatags.description?.trim() || page.data.description || getSiteDescription() || '').trim() || undefined;
|
|
832
907
|
const generatedSocialImage = buildGeneratedOgImagePath(pageUrl);
|
|
833
908
|
const fallbackImage = mergedMetatags['og:image']
|
|
834
909
|
|| mergedMetatags['twitter:image']
|