@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/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
- rmSync(runtimeOutDir, { recursive: true, force: true });
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(source.getPageTree(locale), activePrefix);
461
+ const containerScopedTree = filterTreeBySlugPrefix(localePageTree, activePrefix);
331
462
  const rawTree = scopeTreeToTab(containerScopedTree, currentTabSlug, containerSlug);
332
- const navbarTabs = buildNavbarTabs(source.getPageTree(locale)) ?? [];
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(rawTree, iconLibrary);
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
- text: tabText,
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
- <div className="velu-sidebar-banner">
377
- <ProductSwitcher products={products} iconLibrary={iconLibrary} />
378
- </div>
379
- ) : undefined,
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 { getApiConfig, getLanguages, getVersionOptions, getProductOptions, getSeoConfig, getSiteName, getSiteOrigin } from '@/lib/velu';
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
- async function loadMarkdownForSlug(slug: string[], locale: string, hasI18n: boolean): Promise<string | undefined> {
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
- return await readFile(filePath, 'utf-8');
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 sourceMarkdown = await loadMarkdownForSlug(pageSlug, locale, hasI18n);
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
- Powered by <a href="https://getvelu.com" target="_blank" rel="noopener noreferrer">Velu</a>
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 sourceMarkdown = await loadMarkdownForSlug(pageSlug, locale, hasI18n);
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']