@aravindc26/velu 0.10.0 → 0.11.1

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.
Files changed (65) hide show
  1. package/package.json +15 -6
  2. package/schema/velu.schema.json +1864 -30
  3. package/src/build.ts +1161 -180
  4. package/src/cli.ts +121 -16
  5. package/src/engine/_server.mjs +1708 -192
  6. package/src/engine/app/(docs)/[...slug]/layout.tsx +377 -0
  7. package/src/engine/app/(docs)/[...slug]/page.tsx +917 -0
  8. package/src/engine/app/(docs)/layout.tsx +1 -13
  9. package/src/engine/app/api/proxy/route.ts +23 -0
  10. package/src/engine/app/copy-page.css +59 -1
  11. package/src/engine/app/global.css +3487 -6
  12. package/src/engine/app/layout.tsx +59 -8
  13. package/src/engine/app/llms-file/route.ts +87 -0
  14. package/src/engine/app/llms-full-file/route.ts +62 -0
  15. package/src/engine/app/md-file/[...slug]/route.ts +409 -0
  16. package/src/engine/app/page.tsx +45 -0
  17. package/src/engine/app/robots.txt/route.ts +61 -0
  18. package/src/engine/app/rss-file/[...slug]/route.ts +176 -0
  19. package/src/engine/app/search.css +20 -0
  20. package/src/engine/app/sitemap.xml/route.ts +80 -0
  21. package/src/engine/components/assistant.tsx +16 -5
  22. package/src/engine/components/changelog-filters.tsx +114 -0
  23. package/src/engine/components/code-group.tsx +383 -0
  24. package/src/engine/components/color.tsx +118 -0
  25. package/src/engine/components/expandable.tsx +77 -0
  26. package/src/engine/components/icon.tsx +136 -0
  27. package/src/engine/components/image-zoom-fallback.tsx +147 -0
  28. package/src/engine/components/image.tsx +111 -0
  29. package/src/engine/components/lang-switcher.tsx +95 -0
  30. package/src/engine/components/manual-api-playground.tsx +154 -0
  31. package/src/engine/components/mermaid.tsx +142 -0
  32. package/src/engine/components/openapi-toc-sync.tsx +59 -0
  33. package/src/engine/components/openapi.tsx +1679 -0
  34. package/src/engine/components/page-feedback.tsx +153 -0
  35. package/src/engine/components/product-switcher.tsx +102 -0
  36. package/src/engine/components/prompt.tsx +90 -0
  37. package/src/engine/components/providers.tsx +21 -0
  38. package/src/engine/components/search.tsx +70 -3
  39. package/src/engine/components/sidebar-links.tsx +49 -0
  40. package/src/engine/components/synced-tabs.tsx +57 -0
  41. package/src/engine/components/theme-toggle.tsx +39 -0
  42. package/src/engine/components/toc-examples.tsx +110 -0
  43. package/src/engine/components/version-switcher.tsx +89 -0
  44. package/src/engine/components/view.tsx +344 -0
  45. package/src/engine/generated/redirects.ts +3 -0
  46. package/src/engine/lib/changelog.ts +246 -0
  47. package/src/engine/lib/layout.shared.ts +57 -7
  48. package/src/engine/lib/llms.ts +444 -0
  49. package/src/engine/lib/navigation-normalize.mjs +525 -0
  50. package/src/engine/lib/navigation-normalize.ts +695 -0
  51. package/src/engine/lib/redirects.ts +194 -0
  52. package/src/engine/lib/source.ts +121 -4
  53. package/src/engine/lib/velu.ts +635 -5
  54. package/src/engine/mdx-components.tsx +648 -0
  55. package/src/engine/middleware.ts +66 -0
  56. package/src/engine/next.config.mjs +2 -2
  57. package/src/engine/public/icons/cursor-dark.svg +12 -0
  58. package/src/engine/public/icons/cursor-light.svg +12 -0
  59. package/src/engine/source.config.ts +98 -1
  60. package/src/engine/src/components/PageTitle.astro +16 -5
  61. package/src/engine/src/lib/velu.ts +97 -16
  62. package/src/navigation-normalize.ts +686 -0
  63. package/src/themes.ts +6 -6
  64. package/src/validate.ts +235 -24
  65. package/src/engine/app/(docs)/[[...slug]]/page.tsx +0 -69
@@ -0,0 +1,917 @@
1
+ import type { Metadata } from 'next';
2
+ import { notFound } from 'next/navigation';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { parse as parseYaml } from 'yaml';
6
+ import { createRelativeLink } from 'fumadocs-ui/mdx';
7
+ import {
8
+ DocsBody,
9
+ DocsDescription,
10
+ DocsPage,
11
+ DocsTitle,
12
+ } from 'fumadocs-ui/layouts/notebook/page';
13
+ import { getMDXComponents } from '@/mdx-components';
14
+ import { source } from '@/lib/source';
15
+ import { VeluManualApiPlayground } from '@/components/manual-api-playground';
16
+ import { VeluOpenAPI, VeluOpenAPISchema } from '@/components/openapi';
17
+ import { getApiConfig, getLanguages, getVersionOptions, getProductOptions, getSeoConfig, getSiteName, getSiteOrigin } from '@/lib/velu';
18
+ import { CopyPageButton } from '@/components/copy-page';
19
+ import { ChangelogFilters } from '@/components/changelog-filters';
20
+ import { VeluImageZoomFallback } from '@/components/image-zoom-fallback';
21
+ import { OpenApiTocSync } from '@/components/openapi-toc-sync';
22
+ import { TocExamples } from '@/components/toc-examples';
23
+ import { PageFeedback } from '@/components/page-feedback';
24
+ import { parseChangelogFromMarkdown, parseFrontmatterBoolean } from '@/lib/changelog';
25
+
26
+ interface RouteParams {
27
+ slug?: string[];
28
+ }
29
+
30
+ interface PageProps {
31
+ params: Promise<RouteParams>;
32
+ }
33
+
34
+ type PlaygroundDisplayMode = 'interactive' | 'simple' | 'none' | 'auth';
35
+ type ApiAuthMethod = 'bearer' | 'basic' | 'key' | 'none';
36
+
37
+ interface ParsedApiFrontmatter {
38
+ method: string;
39
+ url: string;
40
+ endpoint: string;
41
+ servers?: Array<{ url: string }>;
42
+ }
43
+
44
+ interface ParsedOpenApiFrontmatter {
45
+ spec: string;
46
+ method: string;
47
+ endpoint: string;
48
+ }
49
+
50
+ interface ParsedOpenApiSchemaFrontmatter {
51
+ spec: string;
52
+ schema: string;
53
+ }
54
+
55
+ interface InlineApiDoc {
56
+ document: Record<string, unknown>;
57
+ endpoint: string;
58
+ method: string;
59
+ }
60
+
61
+ async function loadMarkdownForSlug(slug: string[], locale: string, hasI18n: boolean): Promise<string | undefined> {
62
+ const rel = slug.join('/');
63
+ const docsRoots = [
64
+ join(process.cwd(), 'content', 'docs'),
65
+ join(process.cwd(), '.velu-out', 'content', 'docs'),
66
+ ];
67
+ const roots = hasI18n
68
+ ? docsRoots.flatMap((root) => [join(root, locale), root])
69
+ : docsRoots;
70
+ const paths = roots.flatMap((root) => [join(root, `${rel}.md`), join(root, `${rel}.mdx`)]);
71
+
72
+ for (const filePath of paths) {
73
+ try {
74
+ return await readFile(filePath, 'utf-8');
75
+ } catch {
76
+ // ignore and continue
77
+ }
78
+ }
79
+
80
+ return undefined;
81
+ }
82
+
83
+ function resolveLocaleSlug(slugInput: string[] | undefined) {
84
+ const languages = getLanguages();
85
+ const defaultLanguage = languages[0] ?? 'en';
86
+ const slug = slugInput ?? [];
87
+ const firstSeg = slug[0];
88
+ const hasLocalePrefix = languages.includes(firstSeg ?? '');
89
+
90
+ return {
91
+ defaultLanguage,
92
+ locale: hasLocalePrefix ? firstSeg! : defaultLanguage,
93
+ pageSlug: hasLocalePrefix ? slug.slice(1) : slug,
94
+ };
95
+ }
96
+
97
+ function resolveContextFromSlug(slugInput: string[] | undefined) {
98
+ const languages = getLanguages();
99
+ const versions = getVersionOptions();
100
+ const products = getProductOptions();
101
+ const slug = slugInput ?? [];
102
+
103
+ // Check for language prefix
104
+ const firstSeg = slug[0];
105
+ const hasLocalePrefix = languages.includes(firstSeg ?? '');
106
+ const locale = hasLocalePrefix ? firstSeg! : (languages[0] ?? 'en');
107
+ const remainingSlug = hasLocalePrefix ? slug.slice(1) : slug;
108
+
109
+ // Check for version/product in remaining slug
110
+ const contextSeg = remainingSlug[0] ?? '';
111
+ const version = versions.find((v) => v.slug === contextSeg);
112
+ const product = products.find((p) => p.slug === contextSeg);
113
+
114
+ return {
115
+ locale,
116
+ version: version?.slug,
117
+ product: product?.slug,
118
+ };
119
+ }
120
+
121
+ function parseFrontmatterMap(markdown?: string): Record<string, string> {
122
+ if (!markdown) return {};
123
+ const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
124
+ if (!match) return {};
125
+
126
+ const output: Record<string, string> = {};
127
+ const lines = match[1].split(/\r?\n/);
128
+ for (const rawLine of lines) {
129
+ const line = rawLine.trim();
130
+ if (!line || line.startsWith('#')) continue;
131
+ const entry = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.+)$/);
132
+ if (!entry) continue;
133
+ const key = entry[1];
134
+ const rawValue = entry[2].trim();
135
+ const value = rawValue.replace(/^['"]|['"]$/g, '').trim();
136
+ output[key] = value;
137
+ }
138
+
139
+ return output;
140
+ }
141
+
142
+ function parseFrontmatterData(markdown?: string): Record<string, unknown> {
143
+ if (!markdown) return {};
144
+ const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
145
+ if (!match) return {};
146
+ try {
147
+ const parsed = parseYaml(match[1]);
148
+ return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
149
+ } catch {
150
+ return {};
151
+ }
152
+ }
153
+
154
+ function normalizeBoolean(value: unknown): boolean | undefined {
155
+ if (typeof value === 'boolean') return value;
156
+ if (typeof value === 'string') {
157
+ const normalized = value.trim().toLowerCase();
158
+ if (normalized === 'true' || normalized === 'yes' || normalized === '1') return true;
159
+ if (normalized === 'false' || normalized === 'no' || normalized === '0') return false;
160
+ }
161
+ return undefined;
162
+ }
163
+
164
+ function normalizeMetatagMap(value: unknown): Record<string, string> {
165
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
166
+ const output: Record<string, string> = {};
167
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
168
+ const tag = key.trim();
169
+ if (!tag) continue;
170
+ if (typeof raw === 'string') {
171
+ const normalized = raw.trim();
172
+ if (normalized) output[tag] = normalized;
173
+ continue;
174
+ }
175
+ if (typeof raw === 'number' || typeof raw === 'boolean') {
176
+ output[tag] = String(raw);
177
+ }
178
+ }
179
+ return output;
180
+ }
181
+
182
+ function parseRobotsDirectives(value: string | undefined): Metadata['robots'] | undefined {
183
+ if (!value) return undefined;
184
+ const tokens = value
185
+ .split(',')
186
+ .map((token) => token.trim().toLowerCase())
187
+ .filter(Boolean);
188
+ if (tokens.length === 0) return undefined;
189
+
190
+ const hasNoindex = tokens.includes('noindex') || tokens.includes('none');
191
+ const hasIndex = tokens.includes('index') || tokens.includes('all');
192
+ const hasNofollow = tokens.includes('nofollow') || tokens.includes('none');
193
+ const hasFollow = tokens.includes('follow') || tokens.includes('all');
194
+
195
+ if (!hasNoindex && !hasIndex && !hasNofollow && !hasFollow) return undefined;
196
+
197
+ return {
198
+ ...(hasNoindex ? { index: false } : hasIndex ? { index: true } : {}),
199
+ ...(hasNofollow ? { follow: false } : hasFollow ? { follow: true } : {}),
200
+ };
201
+ }
202
+
203
+ function normalizeImageList(raw: string | undefined): string[] {
204
+ if (!raw) return [];
205
+ return raw
206
+ .split(',')
207
+ .map((entry) => entry.trim())
208
+ .filter((entry) => entry.length > 0);
209
+ }
210
+
211
+ function normalizeDocPath(value: string): string {
212
+ const withLeading = value.startsWith('/') ? value : `/${value}`;
213
+ const collapsed = withLeading.replace(/\/{2,}/g, '/');
214
+ if (collapsed !== '/' && collapsed.endsWith('/')) return collapsed.slice(0, -1);
215
+ return collapsed;
216
+ }
217
+
218
+ function toAbsoluteMetaUrl(origin: string, value: string): string {
219
+ const trimmed = value.trim();
220
+ if (!trimmed) return trimmed;
221
+ if (/^https?:\/\//i.test(trimmed)) return trimmed;
222
+ return `${origin}${normalizeDocPath(trimmed)}`;
223
+ }
224
+
225
+ function parseKeywords(frontmatterData: Record<string, unknown>, fromMetatags: string | undefined): string[] | undefined {
226
+ if (fromMetatags) {
227
+ const entries = fromMetatags.split(',').map((entry) => entry.trim()).filter(Boolean);
228
+ return entries.length > 0 ? entries : undefined;
229
+ }
230
+
231
+ const raw = frontmatterData.keywords;
232
+ if (typeof raw === 'string') {
233
+ const entries = raw.split(',').map((entry) => entry.trim()).filter(Boolean);
234
+ return entries.length > 0 ? entries : undefined;
235
+ }
236
+
237
+ if (Array.isArray(raw)) {
238
+ const entries = raw
239
+ .filter((entry): entry is string => typeof entry === 'string')
240
+ .map((entry) => entry.trim())
241
+ .filter(Boolean);
242
+ return entries.length > 0 ? entries : undefined;
243
+ }
244
+
245
+ return undefined;
246
+ }
247
+
248
+ function buildGeneratedOgImagePath(pageUrl: string): string {
249
+ const normalized = normalizeDocPath(pageUrl);
250
+ if (normalized === '/') return '/og/index.svg';
251
+ return `/og${normalized}.svg`;
252
+ }
253
+
254
+ function extractFrontmatterMetatags(frontmatterData: Record<string, unknown>): Record<string, string> {
255
+ const allowedSimpleKeys = new Set([
256
+ 'title',
257
+ 'description',
258
+ 'canonical',
259
+ 'robots',
260
+ 'keywords',
261
+ 'author',
262
+ 'googlebot',
263
+ 'google',
264
+ 'google-site-verification',
265
+ 'generator',
266
+ 'theme-color',
267
+ 'color-scheme',
268
+ 'format-detection',
269
+ 'referrer',
270
+ 'refresh',
271
+ 'rating',
272
+ 'revisit-after',
273
+ 'language',
274
+ 'copyright',
275
+ 'reply-to',
276
+ 'distribution',
277
+ 'coverage',
278
+ 'category',
279
+ 'target',
280
+ 'HandheldFriendly',
281
+ 'MobileOptimized',
282
+ 'apple-mobile-web-app-capable',
283
+ 'apple-mobile-web-app-status-bar-style',
284
+ 'apple-mobile-web-app-title',
285
+ 'application-name',
286
+ 'msapplication-TileColor',
287
+ 'msapplication-TileImage',
288
+ 'msapplication-config',
289
+ 'viewport',
290
+ 'charset',
291
+ ]);
292
+
293
+ const output: Record<string, string> = {};
294
+ for (const [key, raw] of Object.entries(frontmatterData)) {
295
+ if (key === 'metatags' || key === 'keywords' || key === 'noindex' || key === 'hidden') continue;
296
+ if (!key.includes(':') && !allowedSimpleKeys.has(key)) continue;
297
+
298
+ if (typeof raw === 'string') {
299
+ const trimmed = raw.trim();
300
+ if (trimmed) output[key] = trimmed;
301
+ continue;
302
+ }
303
+
304
+ if (typeof raw === 'number' || typeof raw === 'boolean') {
305
+ output[key] = String(raw);
306
+ continue;
307
+ }
308
+
309
+ if (Array.isArray(raw)) {
310
+ const entries = raw
311
+ .filter((entry): entry is string | number | boolean => ['string', 'number', 'boolean'].includes(typeof entry))
312
+ .map((entry) => String(entry).trim())
313
+ .filter(Boolean);
314
+ if (entries.length > 0) output[key] = entries.join(', ');
315
+ }
316
+ }
317
+
318
+ return output;
319
+ }
320
+
321
+ function resolveCanonicalUrl(siteOrigin: string, pageUrl: string, canonicalMeta?: string): string {
322
+ const normalizedPagePath = normalizeDocPath(pageUrl);
323
+ if (!canonicalMeta) return `${siteOrigin}${normalizedPagePath}`;
324
+
325
+ const raw = canonicalMeta.trim();
326
+ if (!raw) return `${siteOrigin}${normalizedPagePath}`;
327
+
328
+ if (/^https?:\/\//i.test(raw)) {
329
+ try {
330
+ const parsed = new URL(raw);
331
+ const hasOnlyOriginPath = parsed.pathname === '/' && !parsed.search && !parsed.hash;
332
+ if (hasOnlyOriginPath) return `${parsed.origin}${normalizedPagePath}`;
333
+ return parsed.toString();
334
+ } catch {
335
+ return `${siteOrigin}${normalizedPagePath}`;
336
+ }
337
+ }
338
+
339
+ return toAbsoluteMetaUrl(siteOrigin, raw);
340
+ }
341
+
342
+ function normalizePlaygroundDisplay(value: string | undefined, fallback: PlaygroundDisplayMode): PlaygroundDisplayMode {
343
+ if (value === 'interactive' || value === 'simple' || value === 'none') return value;
344
+ if (value === 'auth') return 'none';
345
+ if (value === 'show') return 'interactive';
346
+ if (value === 'hide') return 'none';
347
+ return fallback === 'auth' ? 'none' : fallback;
348
+ }
349
+
350
+ function normalizeAuthMethod(value: string | undefined, fallback: ApiAuthMethod): ApiAuthMethod {
351
+ if (value === 'bearer' || value === 'basic' || value === 'key' || value === 'none') return value;
352
+ return fallback;
353
+ }
354
+
355
+ function normalizeServerList(servers: string[] | undefined): string[] {
356
+ if (!servers || servers.length === 0) return [];
357
+
358
+ const seen = new Set<string>();
359
+ const normalized: string[] = [];
360
+ for (const rawServer of servers) {
361
+ const trimmed = String(rawServer ?? '').trim();
362
+ if (!trimmed) continue;
363
+ try {
364
+ const parsed = new URL(trimmed);
365
+ const normalizedServer = parsed.toString().replace(/\/+$/, '');
366
+ if (seen.has(normalizedServer)) continue;
367
+ seen.add(normalizedServer);
368
+ normalized.push(normalizedServer);
369
+ } catch {
370
+ // ignore invalid server URLs from config
371
+ }
372
+ }
373
+ return normalized;
374
+ }
375
+
376
+ function resolveAbsoluteUrl(server: string, endpoint: string): string {
377
+ const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
378
+ const parsed = new URL(server);
379
+ const basePath = parsed.pathname.replace(/\/+$/, '');
380
+ const endpointPath = normalizedEndpoint.replace(/^\/+/, '');
381
+ const path = `${basePath}/${endpointPath}`.replace(/\/{2,}/g, '/');
382
+ return `${parsed.origin}${path.startsWith('/') ? path : `/${path}`}`;
383
+ }
384
+
385
+ function parseApiFrontmatter(rawValue: string | undefined, serverUrls?: string[]): ParsedApiFrontmatter | null {
386
+ if (!rawValue) return null;
387
+ const match = rawValue.match(/^([A-Za-z]+)\s+(.+)$/);
388
+ if (!match) return null;
389
+
390
+ const method = match[1].toUpperCase();
391
+ const target = match[2].trim();
392
+ if (!target) return null;
393
+
394
+ const configuredServers = normalizeServerList(serverUrls);
395
+
396
+ if (/^https?:\/\//i.test(target)) {
397
+ try {
398
+ const parsedUrl = new URL(target);
399
+ const endpoint = parsedUrl.pathname || '/';
400
+ return {
401
+ method,
402
+ url: target,
403
+ endpoint,
404
+ servers: [{ url: `${parsedUrl.protocol}//${parsedUrl.host}` }],
405
+ };
406
+ } catch {
407
+ // Fall back to raw URL handling below.
408
+ }
409
+
410
+ const endpoint = target.startsWith('/') ? target : `/${target.replace(/^\/+/, '')}`;
411
+ return { method, url: target, endpoint };
412
+ }
413
+
414
+ const endpoint = target.startsWith('/') ? target : `/${target.replace(/^\/+/, '')}`;
415
+ if (configuredServers.length > 0) {
416
+ const url = resolveAbsoluteUrl(configuredServers[0], endpoint);
417
+ return {
418
+ method,
419
+ url,
420
+ endpoint,
421
+ servers: configuredServers.map((server) => ({ url: server })),
422
+ };
423
+ }
424
+
425
+ return { method, url: endpoint, endpoint };
426
+ }
427
+
428
+ function parseOpenApiFrontmatter(rawValue: string | undefined, defaultSpec?: string): ParsedOpenApiFrontmatter | null {
429
+ if (!rawValue) return null;
430
+ const trimmed = rawValue.trim();
431
+ if (!trimmed) return null;
432
+
433
+ const withInlineSpec = trimmed.match(/^(\S+)\s+([A-Za-z]+)\s+(.+)$/);
434
+ if (withInlineSpec) {
435
+ const method = withInlineSpec[2].toUpperCase();
436
+ const endpoint = withInlineSpec[3].trim();
437
+ if (method !== 'WEBHOOK' && !endpoint.startsWith('/')) return null;
438
+ if (!endpoint) return null;
439
+ return {
440
+ spec: withInlineSpec[1],
441
+ method,
442
+ endpoint,
443
+ };
444
+ }
445
+
446
+ const withDefaultSpec = trimmed.match(/^([A-Za-z]+)\s+(.+)$/);
447
+ if (withDefaultSpec && defaultSpec) {
448
+ const method = withDefaultSpec[1].toUpperCase();
449
+ const endpoint = withDefaultSpec[2].trim();
450
+ if (method !== 'WEBHOOK' && !endpoint.startsWith('/')) return null;
451
+ if (!endpoint) return null;
452
+ return {
453
+ spec: defaultSpec,
454
+ method,
455
+ endpoint,
456
+ };
457
+ }
458
+
459
+ return null;
460
+ }
461
+
462
+ function parseOpenApiSchemaFrontmatter(rawValue: string | undefined, defaultSpec?: string): ParsedOpenApiSchemaFrontmatter | null {
463
+ if (!rawValue) return null;
464
+ const trimmed = rawValue.trim();
465
+ if (!trimmed) return null;
466
+
467
+ const withInlineSpec = trimmed.match(/^(\S+)\s+(.+)$/);
468
+ if (withInlineSpec) {
469
+ const schema = withInlineSpec[2].trim();
470
+ if (!schema) return null;
471
+ return {
472
+ spec: withInlineSpec[1],
473
+ schema,
474
+ };
475
+ }
476
+
477
+ if (!defaultSpec) return null;
478
+ return {
479
+ spec: defaultSpec,
480
+ schema: trimmed,
481
+ };
482
+ }
483
+
484
+ function buildInlineApiDoc(
485
+ parsed: ParsedApiFrontmatter,
486
+ pageTitle: string,
487
+ pageDescription: string | undefined,
488
+ authMethod: ApiAuthMethod,
489
+ authName: string | undefined,
490
+ ): InlineApiDoc {
491
+ const method = parsed.method.toUpperCase();
492
+ let endpointPath = parsed.endpoint.trim();
493
+ if (!endpointPath.startsWith('/')) endpointPath = `/${endpointPath.replace(/^\/+/, '')}`;
494
+
495
+ const lowerMethod = method.toLowerCase();
496
+ const operation: Record<string, unknown> = {
497
+ summary: pageTitle,
498
+ description: pageDescription,
499
+ responses: {
500
+ 200: {
501
+ description: 'Successful response',
502
+ },
503
+ },
504
+ };
505
+
506
+ const securitySchemes: Record<string, unknown> = {};
507
+ if (authMethod === 'bearer') {
508
+ securitySchemes.bearerAuth = { type: 'http', scheme: 'bearer' };
509
+ operation.security = [{ bearerAuth: [] }];
510
+ }
511
+ if (authMethod === 'basic') {
512
+ securitySchemes.basicAuth = { type: 'http', scheme: 'basic' };
513
+ operation.security = [{ basicAuth: [] }];
514
+ }
515
+ if (authMethod === 'key') {
516
+ securitySchemes.apiKeyAuth = { type: 'apiKey', in: 'header', name: authName || 'x-api-key' };
517
+ operation.security = [{ apiKeyAuth: [] }];
518
+ }
519
+
520
+ const document: Record<string, unknown> = {
521
+ openapi: '3.1.0',
522
+ info: {
523
+ title: pageTitle || 'API',
524
+ version: '1.0.0',
525
+ ...(pageDescription ? { description: pageDescription } : {}),
526
+ },
527
+ paths: {
528
+ [endpointPath]: {
529
+ [lowerMethod]: operation,
530
+ },
531
+ },
532
+ };
533
+
534
+ if (Array.isArray(parsed.servers) && parsed.servers.length > 0) {
535
+ document.servers = parsed.servers;
536
+ }
537
+
538
+ if (Object.keys(securitySchemes).length > 0) {
539
+ document.components = { securitySchemes };
540
+ }
541
+
542
+ return {
543
+ document,
544
+ endpoint: endpointPath,
545
+ method,
546
+ };
547
+ }
548
+
549
+ export default async function Page({ params }: PageProps) {
550
+ const resolvedParams = await params;
551
+ const { locale, pageSlug } = resolveLocaleSlug(resolvedParams.slug);
552
+ const { locale: filterLocale, version, product } = resolveContextFromSlug(resolvedParams.slug);
553
+ const hasI18n = getLanguages().length > 1;
554
+
555
+ const page = hasI18n ? source.getPage(pageSlug, locale) : source.getPage(pageSlug);
556
+
557
+ if (!page) notFound();
558
+
559
+ const MDX = page.data.body;
560
+ const sourceMarkdown = await loadMarkdownForSlug(pageSlug, locale, hasI18n);
561
+ const pageDataRecord = (page.data as unknown) as Record<string, unknown>;
562
+ const dataMarkdown = typeof pageDataRecord.processedMarkdown === 'string'
563
+ ? String(pageDataRecord.processedMarkdown)
564
+ : undefined;
565
+ const effectiveMarkdown = sourceMarkdown ?? dataMarkdown;
566
+ const frontmatter = parseFrontmatterMap(effectiveMarkdown);
567
+ const apiConfig = getApiConfig();
568
+ const hasExplicitApiRendering = typeof effectiveMarkdown === 'string'
569
+ && (/<(?:APIPlayground|ApiPlayground|OpenAPI)\b/.test(effectiveMarkdown)
570
+ || /@scalar\/api-reference|id=['"]api-reference['"]|createApiReference/.test(effectiveMarkdown));
571
+ const configuredMdxServers = (apiConfig.mdxServers && apiConfig.mdxServers.length > 0)
572
+ ? apiConfig.mdxServers
573
+ : [
574
+ ...(apiConfig.mdxServer ? [apiConfig.mdxServer] : []),
575
+ ...(apiConfig.baseUrl ? [apiConfig.baseUrl] : []),
576
+ ];
577
+ const parsedApiFrontmatter = parseApiFrontmatter(
578
+ frontmatter.api ?? (typeof pageDataRecord.api === 'string' ? pageDataRecord.api : undefined),
579
+ configuredMdxServers,
580
+ );
581
+ const parsedOpenApiFrontmatter = parseOpenApiFrontmatter(
582
+ frontmatter.openapi ?? (typeof pageDataRecord.openapi === 'string' ? pageDataRecord.openapi : undefined),
583
+ apiConfig.defaultOpenApiSpec,
584
+ );
585
+ const parsedOpenApiSchemaFrontmatter = parseOpenApiSchemaFrontmatter(
586
+ frontmatter['openapi-schema'] ?? (typeof pageDataRecord['openapi-schema'] === 'string' ? pageDataRecord['openapi-schema'] : undefined),
587
+ apiConfig.defaultOpenApiSpec,
588
+ );
589
+ const playgroundDisplay = normalizePlaygroundDisplay(frontmatter.playground, apiConfig.playgroundDisplay);
590
+ const proxyUrl = apiConfig.playgroundProxyEnabled ? '/api/proxy' : '';
591
+ const authMethod = normalizeAuthMethod(frontmatter.authMethod, apiConfig.authMethod);
592
+ const inlineApiDoc = parsedApiFrontmatter
593
+ ? buildInlineApiDoc(parsedApiFrontmatter, page.data.title, page.data.description, authMethod, apiConfig.authName)
594
+ : null;
595
+ const hasPanelExamples = typeof effectiveMarkdown === 'string'
596
+ && /<(?:Panel|RequestExample|ResponseExample)(?:\s|>)/.test(effectiveMarkdown);
597
+ const parsedChangelog = parseChangelogFromMarkdown(effectiveMarkdown);
598
+ const hasChangelog = parsedChangelog.updates.length > 0;
599
+ const hasChangelogTags = parsedChangelog.tags.length > 0;
600
+ const isDeprecatedPage = parseFrontmatterBoolean(effectiveMarkdown, 'deprecated')
601
+ || frontmatter.status?.trim().toLowerCase() === 'deprecated'
602
+ || (pageDataRecord.deprecated === true)
603
+ || String((pageDataRecord.status ?? '')).trim().toLowerCase() === 'deprecated';
604
+ const showRssButton = hasChangelog && parseFrontmatterBoolean(effectiveMarkdown, 'rss');
605
+ const sourcePageUrl = (page as unknown as { url?: string }).url;
606
+ const fallbackPath = `/${(resolvedParams.slug ?? []).join('/')}`.replace(/\/{2,}/g, '/');
607
+ const pageUrl = (typeof sourcePageUrl === 'string' && sourcePageUrl.trim())
608
+ ? sourcePageUrl
609
+ : (fallbackPath === '' ? '/' : fallbackPath);
610
+ const rssHref = `${pageUrl.replace(/\/$/, '') || ''}/rss.xml`;
611
+ const shouldReplaceTocWithApiExample = !hasExplicitApiRendering && Boolean(inlineApiDoc) && playgroundDisplay === 'interactive';
612
+ const shouldShowOpenApiExampleInToc = !hasExplicitApiRendering && !parsedApiFrontmatter && Boolean(parsedOpenApiFrontmatter);
613
+ const hasApiTocRail = shouldReplaceTocWithApiExample || shouldShowOpenApiExampleInToc;
614
+ const apiTocHeader = hasApiTocRail ? (
615
+ <div className="velu-api-toc-rail">
616
+ <div id="velu-api-toc-rail-host" />
617
+ </div>
618
+ ) : undefined;
619
+ const toc = hasChangelog ? parsedChangelog.toc : page.data.toc;
620
+ const tableOfContentHeader = apiTocHeader ?? (hasPanelExamples ? <div className="velu-toc-panel-rail" /> : undefined);
621
+ const orderedPages = hasI18n ? source.getPages(locale) : source.getPages();
622
+ const currentPageUrl = (typeof sourcePageUrl === 'string' && sourcePageUrl.trim())
623
+ ? sourcePageUrl
624
+ : pageUrl;
625
+ const currentIndex = orderedPages.findIndex((entry) => entry.url === currentPageUrl);
626
+ const previousPage = currentIndex > 0 ? orderedPages[currentIndex - 1] : undefined;
627
+ const nextPage = currentIndex >= 0 && currentIndex < orderedPages.length - 1 ? orderedPages[currentIndex + 1] : undefined;
628
+
629
+ // Build pagefind filter attributes
630
+ const metaAttrs: string[] = [`title:${page.data.title}`];
631
+ const filterAttrs: string[] = [];
632
+ if (hasI18n) {
633
+ metaAttrs.push(`language:${filterLocale}`);
634
+ filterAttrs.push(`language:${filterLocale}`);
635
+ }
636
+ if (version) {
637
+ metaAttrs.push(`version:${version}`);
638
+ filterAttrs.push(`version:${version}`);
639
+ }
640
+ if (product) {
641
+ metaAttrs.push(`product:${product}`);
642
+ filterAttrs.push(`product:${product}`);
643
+ }
644
+
645
+ return (
646
+ <DocsPage
647
+ toc={toc}
648
+ full={hasChangelog ? false : (hasApiTocRail ? false : page.data.full)}
649
+ tableOfContent={tableOfContentHeader ? { header: tableOfContentHeader } : undefined}
650
+ footer={{ enabled: false }}
651
+ >
652
+ <div
653
+ data-pagefind-body
654
+ data-pagefind-meta={metaAttrs.join(',')}
655
+ data-pagefind-filter={filterAttrs.length > 0 ? filterAttrs.join(',') : undefined}
656
+ >
657
+ <TocExamples />
658
+ <OpenApiTocSync enabled={hasApiTocRail} />
659
+ {hasChangelogTags ? <ChangelogFilters tags={parsedChangelog.tags} /> : null}
660
+ <VeluImageZoomFallback />
661
+ <div className="velu-title-row">
662
+ <div className="velu-title-main">
663
+ <DocsTitle>{page.data.title}</DocsTitle>
664
+ {isDeprecatedPage ? <span className="velu-pill velu-pill-deprecated velu-page-deprecated-badge">Deprecated</span> : null}
665
+ </div>
666
+ <div className="velu-title-actions">
667
+ <CopyPageButton />
668
+ {showRssButton ? (
669
+ <a className="velu-rss-button" href={rssHref} aria-label="Subscribe to this changelog RSS feed">
670
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
671
+ <path d="M4 11a9 9 0 0 1 9 9" />
672
+ <path d="M4 4a16 16 0 0 1 16 16" />
673
+ <circle cx="5" cy="19" r="1.5" />
674
+ </svg>
675
+ </a>
676
+ ) : null}
677
+ </div>
678
+ </div>
679
+ {page.data.description ? <DocsDescription>{page.data.description}</DocsDescription> : null}
680
+ <DocsBody>
681
+ {!hasExplicitApiRendering && inlineApiDoc && playgroundDisplay === 'interactive' ? (
682
+ <VeluOpenAPI
683
+ className="velu-api-playground"
684
+ inlineDocument={inlineApiDoc.document}
685
+ inlineDocumentId={`velu-inline-${pageUrl.replace(/[^a-z0-9]+/gi, '-').toLowerCase() || 'api'}`}
686
+ endpoint={inlineApiDoc.endpoint}
687
+ method={inlineApiDoc.method}
688
+ proxyUrl={proxyUrl}
689
+ exampleLanguages={apiConfig.exampleLanguages}
690
+ exampleAutogenerate={apiConfig.exampleAutogenerate}
691
+ layout="playground"
692
+ showTitle={false}
693
+ showDescription={false}
694
+ />
695
+ ) : null}
696
+ {!hasExplicitApiRendering && parsedApiFrontmatter && playgroundDisplay === 'simple' ? (
697
+ <VeluManualApiPlayground
698
+ method={parsedApiFrontmatter.method}
699
+ url={parsedApiFrontmatter.url}
700
+ display="simple"
701
+ />
702
+ ) : null}
703
+ {!hasExplicitApiRendering && !parsedApiFrontmatter && parsedOpenApiFrontmatter ? (
704
+ <VeluOpenAPI
705
+ className="velu-api-playground"
706
+ schemaSource={parsedOpenApiFrontmatter.spec}
707
+ endpoint={parsedOpenApiFrontmatter.endpoint}
708
+ method={parsedOpenApiFrontmatter.method}
709
+ proxyUrl={proxyUrl}
710
+ exampleLanguages={apiConfig.exampleLanguages}
711
+ exampleAutogenerate={apiConfig.exampleAutogenerate}
712
+ layout="playground"
713
+ showTitle={false}
714
+ showDescription={false}
715
+ />
716
+ ) : null}
717
+ {!hasExplicitApiRendering && !parsedApiFrontmatter && !parsedOpenApiFrontmatter && parsedOpenApiSchemaFrontmatter ? (
718
+ <VeluOpenAPISchema
719
+ className="velu-openapi-schema-wrapper"
720
+ schemaSource={parsedOpenApiSchemaFrontmatter.spec}
721
+ schema={parsedOpenApiSchemaFrontmatter.schema}
722
+ />
723
+ ) : null}
724
+ <MDX
725
+ components={getMDXComponents({
726
+ a: createRelativeLink(source, page),
727
+ })}
728
+ />
729
+ </DocsBody>
730
+ <section className="velu-page-feedback-wrap" aria-label="Page feedback">
731
+ <PageFeedback />
732
+ {(previousPage || nextPage) ? (
733
+ <div className={['velu-page-nav-grid', previousPage && nextPage ? 'velu-page-nav-grid-two' : 'velu-page-nav-grid-one'].join(' ')}>
734
+ {previousPage ? (
735
+ <a href={previousPage.url} className="velu-page-nav-card">
736
+ <p className="velu-page-nav-title">{previousPage.data.title}</p>
737
+ <p className="velu-page-nav-meta">
738
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg>
739
+ <span>{previousPage.data.description ?? 'Previous'}</span>
740
+ </p>
741
+ </a>
742
+ ) : null}
743
+ {nextPage ? (
744
+ <a href={nextPage.url} className="velu-page-nav-card velu-page-nav-card-next">
745
+ <p className="velu-page-nav-title">{nextPage.data.title}</p>
746
+ <p className="velu-page-nav-meta velu-page-nav-meta-next">
747
+ <span>{nextPage.data.description ?? 'Next'}</span>
748
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18l6-6-6-6" /></svg>
749
+ </p>
750
+ </a>
751
+ ) : null}
752
+ </div>
753
+ ) : null}
754
+ </section>
755
+ </div>
756
+ <footer className="velu-footer">
757
+ Powered by <a href="https://getvelu.com" target="_blank" rel="noopener noreferrer">Velu</a>
758
+ </footer>
759
+ </DocsPage>
760
+ );
761
+ }
762
+
763
+ export async function generateStaticParams() {
764
+ const generated = source.generateParams('slug') as Array<{ slug?: string[] }>;
765
+ const seen = new Set<string>();
766
+
767
+ const nonRoot = generated.filter((entry) => {
768
+ const slug = entry.slug ?? [];
769
+ if (slug.length === 0) return false;
770
+ const key = slug.join('/');
771
+ if (seen.has(key)) return false;
772
+ seen.add(key);
773
+ return true;
774
+ });
775
+
776
+ return nonRoot;
777
+ }
778
+
779
+ export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
780
+ const resolvedParams = await params;
781
+ const { locale, pageSlug } = resolveLocaleSlug(resolvedParams.slug);
782
+ const hasI18n = getLanguages().length > 1;
783
+ const seo = getSeoConfig();
784
+ const siteName = getSiteName();
785
+ const siteOrigin = getSiteOrigin();
786
+
787
+ const page = hasI18n ? source.getPage(pageSlug, locale) : source.getPage(pageSlug);
788
+
789
+ if (!page) notFound();
790
+
791
+ const sourceMarkdown = await loadMarkdownForSlug(pageSlug, locale, hasI18n);
792
+ const pageDataRecord = (page.data as unknown) as Record<string, unknown>;
793
+ const dataMarkdown = typeof pageDataRecord.processedMarkdown === 'string'
794
+ ? String(pageDataRecord.processedMarkdown)
795
+ : undefined;
796
+ const effectiveMarkdown = sourceMarkdown ?? dataMarkdown;
797
+ const frontmatterData = parseFrontmatterData(effectiveMarkdown);
798
+ const pageTopLevelMetatags = extractFrontmatterMetatags(frontmatterData);
799
+ const pageNestedMetatags = normalizeMetatagMap(frontmatterData.metatags);
800
+ const mergedMetatags: Record<string, string> = {
801
+ ...seo.metatags,
802
+ ...pageTopLevelMetatags,
803
+ ...pageNestedMetatags,
804
+ };
805
+ const sourcePageUrl = (page as unknown as { url?: string }).url;
806
+ const fallbackPath = `/${(resolvedParams.slug ?? []).join('/')}`.replace(/\/{2,}/g, '/');
807
+ const pageUrl = (typeof sourcePageUrl === 'string' && sourcePageUrl.trim())
808
+ ? sourcePageUrl
809
+ : (fallbackPath === '' ? '/' : fallbackPath);
810
+
811
+ const canonical = resolveCanonicalUrl(siteOrigin, pageUrl, mergedMetatags.canonical);
812
+ const keywords = parseKeywords(frontmatterData, mergedMetatags.keywords);
813
+ const robotsFromMetatag = parseRobotsDirectives(mergedMetatags.robots);
814
+ const noindex = normalizeBoolean(frontmatterData.noindex) === true
815
+ || normalizeBoolean(frontmatterData.hidden) === true
816
+ || parseFrontmatterBoolean(effectiveMarkdown, 'noindex')
817
+ || parseFrontmatterBoolean(effectiveMarkdown, 'hidden')
818
+ || (mergedMetatags.robots ?? '').toLowerCase().includes('noindex')
819
+ || (mergedMetatags.robots ?? '').toLowerCase().includes('none');
820
+ const titleOverride = mergedMetatags.title?.trim();
821
+ const resolvedTitle = titleOverride || `${page.data.title} - ${siteName}`;
822
+ const resolvedDescription = (mergedMetatags.description?.trim() || page.data.description || '').trim() || undefined;
823
+ const generatedSocialImage = buildGeneratedOgImagePath(pageUrl);
824
+ const fallbackImage = mergedMetatags['og:image']
825
+ || mergedMetatags['twitter:image']
826
+ || generatedSocialImage;
827
+ const openGraphImagesRaw = normalizeImageList(mergedMetatags['og:image'] ?? fallbackImage);
828
+ const twitterImagesRaw = normalizeImageList(mergedMetatags['twitter:image'] ?? fallbackImage);
829
+ const ogImageWidth = mergedMetatags['og:image:width'] || '1200';
830
+ const ogImageHeight = mergedMetatags['og:image:height'] || '630';
831
+ const twitterImageWidth = mergedMetatags['twitter:image:width'] || '1200';
832
+ const twitterImageHeight = mergedMetatags['twitter:image:height'] || '630';
833
+ const openGraphImages = openGraphImagesRaw.map((entry) => ({
834
+ url: toAbsoluteMetaUrl(siteOrigin, entry),
835
+ width: Number(ogImageWidth),
836
+ height: Number(ogImageHeight),
837
+ }));
838
+ const twitterImages = twitterImagesRaw.map((entry) => toAbsoluteMetaUrl(siteOrigin, entry));
839
+ const openGraph: NonNullable<Metadata['openGraph']> = {
840
+ type: (mergedMetatags['og:type'] as NonNullable<Metadata['openGraph']>['type']) || 'website',
841
+ siteName: mergedMetatags['og:site_name'] || siteName,
842
+ title: mergedMetatags['og:title'] || resolvedTitle,
843
+ ...(resolvedDescription ? { description: mergedMetatags['og:description'] || resolvedDescription } : {}),
844
+ url: mergedMetatags['og:url'] ? toAbsoluteMetaUrl(siteOrigin, mergedMetatags['og:url']) : canonical,
845
+ ...(mergedMetatags['og:locale'] ? { locale: mergedMetatags['og:locale'] } : {}),
846
+ ...(openGraphImages.length > 0 ? { images: openGraphImages as NonNullable<Metadata['openGraph']>['images'] } : {}),
847
+ };
848
+ const twitter: NonNullable<Metadata['twitter']> = {
849
+ card: (mergedMetatags['twitter:card'] as NonNullable<Metadata['twitter']>['card']) || 'summary_large_image',
850
+ title: mergedMetatags['twitter:title'] || resolvedTitle,
851
+ ...(resolvedDescription ? { description: mergedMetatags['twitter:description'] || resolvedDescription } : {}),
852
+ ...(mergedMetatags['twitter:site'] ? { site: mergedMetatags['twitter:site'] } : {}),
853
+ ...(mergedMetatags['twitter:creator'] ? { creator: mergedMetatags['twitter:creator'] } : {}),
854
+ ...(twitterImages.length > 0 ? { images: twitterImages } : {}),
855
+ };
856
+
857
+ const handledTags = new Set([
858
+ 'canonical',
859
+ 'keywords',
860
+ 'robots',
861
+ 'application-name',
862
+ 'apple-mobile-web-app-title',
863
+ 'apple-mobile-web-app-capable',
864
+ 'apple-mobile-web-app-status-bar-style',
865
+ 'msapplication-TileColor',
866
+ 'og:title',
867
+ 'og:description',
868
+ 'og:url',
869
+ 'og:site_name',
870
+ 'og:type',
871
+ 'og:locale',
872
+ 'og:image',
873
+ 'twitter:card',
874
+ 'twitter:title',
875
+ 'twitter:description',
876
+ 'twitter:site',
877
+ 'twitter:creator',
878
+ 'twitter:image',
879
+ 'og:image:width',
880
+ 'og:image:height',
881
+ 'twitter:image:width',
882
+ 'twitter:image:height',
883
+ 'title',
884
+ 'description',
885
+ 'generator',
886
+ ]);
887
+ const otherMetatags = Object.fromEntries(
888
+ Object.entries(mergedMetatags).filter(([key]) => !handledTags.has(key)),
889
+ );
890
+ if (openGraphImages.length > 0) {
891
+ otherMetatags['og:image:width'] = ogImageWidth;
892
+ otherMetatags['og:image:height'] = ogImageHeight;
893
+ }
894
+ if (twitterImages.length > 0) {
895
+ otherMetatags['twitter:image:width'] = twitterImageWidth;
896
+ otherMetatags['twitter:image:height'] = twitterImageHeight;
897
+ }
898
+ if (noindex) {
899
+ otherMetatags.noindex = 'true';
900
+ }
901
+
902
+ return {
903
+ ...(titleOverride ? { title: { absolute: titleOverride } } : { title: page.data.title }),
904
+ ...(resolvedDescription ? { description: resolvedDescription } : {}),
905
+ ...(keywords && keywords.length > 0 ? { keywords } : {}),
906
+ alternates: { canonical },
907
+ openGraph,
908
+ twitter,
909
+ ...(mergedMetatags.generator ? { generator: mergedMetatags.generator } : {}),
910
+ ...(noindex
911
+ ? { robots: { index: false, follow: false } }
912
+ : robotsFromMetatag
913
+ ? { robots: robotsFromMetatag }
914
+ : { robots: { index: true, follow: true } }),
915
+ ...(Object.keys(otherMetatags).length > 0 ? { other: otherMetatags } : {}),
916
+ };
917
+ }