@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/package.json +1 -1
- package/schema/velu.schema.json +383 -122
- package/src/build.ts +679 -551
- package/src/cli.ts +65 -2
- package/src/engine/app/(docs)/[...slug]/layout.tsx +155 -13
- package/src/engine/app/(docs)/[...slug]/page.tsx +77 -9
- 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/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/search.tsx +136 -49
- package/src/engine/lib/layout.shared.ts +68 -68
- package/src/engine/lib/velu.ts +297 -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, 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
|
-
|
|
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(
|
|
461
|
+
const containerScopedTree = filterTreeBySlugPrefix(localePageTree, activePrefix);
|
|
355
462
|
const rawTree = scopeTreeToTab(containerScopedTree, currentTabSlug, containerSlug);
|
|
356
|
-
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);
|
|
357
467
|
const tabMenuDefinitions = getTabMenuDefinitions();
|
|
358
|
-
const tree = renderIconsInTree(
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
<
|
|
402
|
-
|
|
403
|
-
|
|
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 {
|
|
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';
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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.
|
|
647
|
+
padding-top: 1.1rem;
|
|
585
648
|
border-top: 1px solid var(--color-fd-border);
|
|
586
|
-
|
|
587
|
-
|
|
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);
|