@aravindc26/velu 0.11.6 → 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, readFileSync } 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
  }
@@ -291,6 +304,29 @@ function collectStaticRoutePaths(distDir: string): string[] {
291
304
  return Array.from(routes).sort((a, b) => a.localeCompare(b));
292
305
  }
293
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
+
294
330
  function addStaticRouteCompatibility(outDir: string) {
295
331
  const distDir = join(outDir, "dist");
296
332
  if (!existsSync(distDir)) return;
@@ -349,6 +385,33 @@ function addStaticRouteCompatibility(outDir: string) {
349
385
  writeFileSync(redirectsPath, merged, "utf-8");
350
386
  }
351
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
+
352
415
  console.log(`🔁 Added static compatibility for ${routes.length} routes (${aliasCount} .html aliases, ${redirectAdded} redirects)`);
353
416
  }
354
417
 
@@ -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[];
@@ -222,12 +224,54 @@ function normalizePath(value: string): string {
222
224
  return value.replace(/^\/+|\/+$/g, '').toLowerCase();
223
225
  }
224
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
+
225
233
  function basename(value: string): string {
226
234
  const normalized = normalizePath(value);
227
235
  const parts = normalized.split('/').filter(Boolean);
228
236
  return parts[parts.length - 1] ?? normalized;
229
237
  }
230
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
+
231
275
  function resolveMenuTargetUrl(menuPages: string[], tabUrls: Set<string>): string | undefined {
232
276
  const urls = Array.from(tabUrls);
233
277
  if (urls.length === 0) return undefined;
@@ -271,6 +315,49 @@ function resolveMenuLinksForTab(
271
315
  return best;
272
316
  }
273
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
+
274
361
  function scopeTreeToTab<T extends { children?: unknown[] }>(
275
362
  tree: T,
276
363
  tabSlug?: string,
@@ -341,22 +428,69 @@ function scopeTreeToTab<T extends { children?: unknown[] }>(
341
428
  return { ...tree, children: scopedChildren } as T;
342
429
  }
343
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
+
344
449
  export default async function SlugLayout({ children, params }: SlugLayoutProps) {
345
450
  const resolvedParams = await params;
346
451
  const locale = resolveLocale(resolvedParams.slug);
452
+ const localePageTree = source.getPageTree(locale);
347
453
  const versions = getVersionOptions();
348
454
  const products = getProductOptions();
455
+ const dropdowns = getDropdownOptions();
349
456
  const iconLibrary = getIconLibrary();
350
457
  const currentVersion = resolveCurrentVersion(resolvedParams.slug, versions);
351
458
  const currentProduct = resolveCurrentProduct(resolvedParams.slug, products);
352
459
  const { containerSlug, tabSlug: currentTabSlug } = resolveTabContext(resolvedParams.slug);
353
460
  const activePrefix = currentVersion?.slug ?? currentProduct?.slug;
354
- const containerScopedTree = filterTreeBySlugPrefix(source.getPageTree(locale), activePrefix);
461
+ const containerScopedTree = filterTreeBySlugPrefix(localePageTree, activePrefix);
355
462
  const rawTree = scopeTreeToTab(containerScopedTree, currentTabSlug, containerSlug);
356
- 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);
357
467
  const tabMenuDefinitions = getTabMenuDefinitions();
358
- const tree = renderIconsInTree(rawTree, iconLibrary);
468
+ const tree = renderIconsInTree(activeTree, iconLibrary);
359
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
+ });
360
494
  const headerTabLinks: LinkItemType[] = navbarTabs
361
495
  .map((tab): LinkItemType | null => {
362
496
  const tabText = typeof tab.title === 'string' ? tab.title : '';
@@ -371,22 +505,27 @@ export default async function SlugLayout({ children, params }: SlugLayoutProps)
371
505
  return {
372
506
  type: 'menu',
373
507
  text: tabText,
374
- url: withTrailingSlashUrl(tab.url),
508
+ url: withTrailingSlashUrl(withPrefixedPath(tab.url, requestPathPrefix)),
375
509
  active: 'nested-url',
376
510
  secondary: false,
377
511
  items: menuLinks.map((item) => ({
378
512
  text: item.text,
379
- url: withTrailingSlashUrl(item.url),
513
+ url: withTrailingSlashUrl(withPrefixedPath(item.url, requestPathPrefix)),
380
514
  active: 'nested-url',
381
515
  })),
382
516
  };
383
517
  }
384
518
 
385
519
  return {
386
- text: tabText,
387
- url: withTrailingSlashUrl(tab.url),
388
- active: 'nested-url',
520
+ type: 'custom',
389
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
+ ),
390
529
  };
391
530
  })
392
531
  .filter((link): link is LinkItemType => link !== null);
@@ -395,12 +534,15 @@ export default async function SlugLayout({ children, params }: SlugLayoutProps)
395
534
  <DocsLayout
396
535
  tree={tree}
397
536
  sidebar={{
537
+ tabs: dropdownTabs.length > 0 ? dropdownTabs : undefined,
398
538
  collapsible: true,
399
- banner: products.length > 1 ? (
400
- <div className="velu-sidebar-banner">
401
- <ProductSwitcher products={products} iconLibrary={iconLibrary} />
402
- </div>
403
- ) : undefined,
539
+ banner: products.length > 1
540
+ ? (
541
+ <div className="velu-sidebar-banner">
542
+ <ProductSwitcher products={products} iconLibrary={iconLibrary} />
543
+ </div>
544
+ )
545
+ : undefined,
404
546
  footer: ({ className, children, ...props }: any) => (
405
547
  <div
406
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';
@@ -555,9 +586,11 @@ function buildInlineApiDoc(
555
586
 
556
587
  export default async function Page({ params }: PageProps) {
557
588
  const resolvedParams = await params;
589
+ const metadataConfig = getMetadataConfig();
558
590
  const { locale, pageSlug } = resolveLocaleSlug(resolvedParams.slug);
559
591
  const { locale: filterLocale, version, product } = resolveContextFromSlug(resolvedParams.slug);
560
592
  const hasI18n = getLanguages().length > 1;
593
+ const footerSocials = getFooterSocials();
561
594
 
562
595
  const page = hasI18n ? source.getPage(pageSlug, locale) : source.getPage(pageSlug);
563
596
 
@@ -566,7 +599,11 @@ export default async function Page({ params }: PageProps) {
566
599
  const pageDataRecord = (page.data as unknown) as Record<string, unknown>;
567
600
  const MDX = pageDataRecord.body as any;
568
601
  if (typeof MDX !== 'function') notFound();
569
- 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;
570
607
  const dataMarkdown = typeof pageDataRecord.processedMarkdown === 'string'
571
608
  ? String(pageDataRecord.processedMarkdown)
572
609
  : undefined;
@@ -680,7 +717,7 @@ export default async function Page({ params }: PageProps) {
680
717
  {isDeprecatedPage ? <span className="velu-pill velu-pill-deprecated velu-page-deprecated-badge">Deprecated</span> : null}
681
718
  </div>
682
719
  <div className="velu-title-actions">
683
- <CopyPageButton />
720
+ <CopyPageButton options={getContextualOptions()} mcpUrl={getSiteOrigin() + '/mcp'} />
684
721
  {showRssButton ? (
685
722
  <a className="velu-rss-button" href={rssHref} aria-label="Subscribe to this changelog RSS feed">
686
723
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
@@ -693,6 +730,9 @@ export default async function Page({ params }: PageProps) {
693
730
  </div>
694
731
  </div>
695
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}
696
736
  <DocsBody>
697
737
  {!hasExplicitApiRendering && inlineApiDoc && playgroundDisplay === 'interactive' ? (
698
738
  <VeluOpenAPI
@@ -770,7 +810,34 @@ export default async function Page({ params }: PageProps) {
770
810
  </section>
771
811
  </div>
772
812
  <footer className="velu-footer">
773
- 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>
774
841
  </footer>
775
842
  </DocsPage>
776
843
  );
@@ -804,7 +871,8 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
804
871
 
805
872
  if (!page) notFound();
806
873
 
807
- const sourceMarkdown = await loadMarkdownForSlug(pageSlug, locale, hasI18n);
874
+ const loadedMarkdown = await loadMarkdownForSlug(pageSlug, locale, hasI18n);
875
+ const sourceMarkdown = loadedMarkdown?.content;
808
876
  const pageDataRecord = (page.data as unknown) as Record<string, unknown>;
809
877
  const dataMarkdown = typeof pageDataRecord.processedMarkdown === 'string'
810
878
  ? String(pageDataRecord.processedMarkdown)
@@ -835,7 +903,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
835
903
  || (mergedMetatags.robots ?? '').toLowerCase().includes('none');
836
904
  const titleOverride = mergedMetatags.title?.trim();
837
905
  const resolvedTitle = titleOverride || `${page.data.title} - ${siteName}`;
838
- const resolvedDescription = (mergedMetatags.description?.trim() || page.data.description || '').trim() || undefined;
906
+ const resolvedDescription = (mergedMetatags.description?.trim() || page.data.description || getSiteDescription() || '').trim() || undefined;
839
907
  const generatedSocialImage = buildGeneratedOgImagePath(pageUrl);
840
908
  const fallbackImage = mergedMetatags['og:image']
841
909
  || mergedMetatags['twitter:image']
@@ -32,6 +32,12 @@
32
32
  margin-top: 0.35rem;
33
33
  }
34
34
 
35
+ .velu-page-last-updated {
36
+ margin: 0.35rem 0 0;
37
+ color: var(--color-fd-muted-foreground, #a1a1aa);
38
+ font-size: 0.8125rem;
39
+ }
40
+
35
41
  .velu-rss-button {
36
42
  display: inline-flex;
37
43
  align-items: center;
@@ -155,10 +161,20 @@
155
161
  background-color: var(--color-fd-accent, #27272a);
156
162
  }
157
163
 
164
+ .velu-copy-option-icon {
165
+ display: inline-flex;
166
+ align-items: center;
167
+ justify-content: center;
168
+ width: 18px;
169
+ height: 18px;
170
+ flex-shrink: 0;
171
+ margin-top: 0.15rem;
172
+ }
173
+
174
+ .velu-copy-option-icon svg,
158
175
  .velu-copy-option svg {
159
176
  flex-shrink: 0;
160
177
  opacity: 0.7;
161
- margin-top: 0.15rem;
162
178
  overflow: visible;
163
179
  }
164
180
 
@@ -7,6 +7,69 @@ body {
7
7
  min-height: 100vh;
8
8
  }
9
9
 
10
+ body:has(.velu-announcement) #nd-docs-layout,
11
+ body:has(.velu-announcement) #nd-notebook-layout {
12
+ --fd-banner-height: var(--velu-announcement-h, 0px) !important;
13
+ }
14
+
15
+ /* ── Global announcement banner ── */
16
+ .velu-announcement {
17
+ position: sticky;
18
+ top: 0;
19
+ z-index: 30;
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: center;
23
+ width: 100%;
24
+ padding: 0.5rem 1rem;
25
+ background-color: var(--color-fd-primary);
26
+ color: #fff;
27
+ font-size: 0.875rem;
28
+ line-height: 1.4;
29
+ text-align: center;
30
+ gap: 0.5rem;
31
+ border-radius: 0;
32
+ margin: 0;
33
+ border: none;
34
+ }
35
+
36
+ .velu-announcement a {
37
+ color: #fff;
38
+ text-decoration: underline;
39
+ text-underline-offset: 2px;
40
+ }
41
+
42
+ .velu-announcement a:hover {
43
+ opacity: 0.85;
44
+ }
45
+
46
+ .velu-announcement-content {
47
+ flex: 1;
48
+ min-width: 0;
49
+ }
50
+
51
+ .velu-announcement-dismiss {
52
+ flex-shrink: 0;
53
+ display: inline-flex;
54
+ align-items: center;
55
+ justify-content: center;
56
+ width: 1.5rem;
57
+ height: 1.5rem;
58
+ padding: 0;
59
+ border: none;
60
+ border-radius: 0.25rem;
61
+ background: transparent;
62
+ color: #fff;
63
+ font-size: 1rem;
64
+ cursor: pointer;
65
+ opacity: 0.8;
66
+ transition: opacity 0.15s;
67
+ }
68
+
69
+ .velu-announcement-dismiss:hover {
70
+ opacity: 1;
71
+ }
72
+
10
73
  .velu-nav-brand {
11
74
  display: inline-flex;
12
75
  align-items: center;
@@ -581,13 +644,50 @@ nextjs-portal {
581
644
 
582
645
  .velu-footer {
583
646
  margin-top: 3rem;
584
- padding-top: 1.5rem;
647
+ padding-top: 1.1rem;
585
648
  border-top: 1px solid var(--color-fd-border);
586
- text-align: right;
587
- font-size: 1.2rem;
649
+ display: flex;
650
+ align-items: center;
651
+ justify-content: space-between;
652
+ gap: 1rem;
653
+ flex-wrap: wrap;
654
+ font-size: 1rem;
588
655
  color: var(--color-fd-muted-foreground);
589
656
  }
590
657
 
658
+ .velu-footer-socials {
659
+ display: inline-flex;
660
+ align-items: center;
661
+ gap: 1rem;
662
+ }
663
+
664
+ .velu-footer-social-link {
665
+ display: inline-flex;
666
+ align-items: center;
667
+ justify-content: center;
668
+ color: var(--color-fd-muted-foreground);
669
+ opacity: 0.78;
670
+ text-decoration: none;
671
+ transition: color 0.15s, opacity 0.15s;
672
+ }
673
+
674
+ .velu-footer-social-link:hover {
675
+ color: var(--color-fd-foreground);
676
+ opacity: 1;
677
+ }
678
+
679
+ .velu-footer-social-icon {
680
+ width: 1.05rem;
681
+ height: 1.05rem;
682
+ flex-shrink: 0;
683
+ }
684
+
685
+ .velu-footer-powered {
686
+ margin-left: auto;
687
+ font-size: 1.15rem;
688
+ line-height: 1.3;
689
+ }
690
+
591
691
  .velu-page-feedback-wrap {
592
692
  margin-top: 2.75rem;
593
693
  display: grid;
@@ -867,16 +967,22 @@ nextjs-portal {
867
967
  }
868
968
  }
869
969
 
870
- .velu-footer a {
970
+ .velu-footer-powered a {
871
971
  color: var(--color-fd-primary);
872
972
  font-weight: 600;
873
973
  text-decoration: none;
874
974
  }
875
975
 
876
- .velu-footer a:hover {
976
+ .velu-footer-powered a:hover {
877
977
  text-decoration: underline;
878
978
  }
879
979
 
980
+ @media (max-width: 768px) {
981
+ .velu-footer-powered {
982
+ margin-left: 0;
983
+ }
984
+ }
985
+
880
986
  [data-card].velu-card-horizontal {
881
987
  display: grid !important;
882
988
  grid-template-columns: 2.25rem minmax(0, 1fr);