@aravindc26/velu 0.11.0 → 0.11.3
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 +15 -6
- package/schema/velu.schema.json +1251 -115
- package/src/build.ts +1121 -304
- package/src/cli.ts +90 -26
- package/src/engine/_server.mjs +1684 -277
- package/src/engine/app/(docs)/[...slug]/layout.tsx +371 -0
- package/src/engine/app/(docs)/[...slug]/page.tsx +926 -0
- package/src/engine/app/api/proxy/route.ts +23 -0
- package/src/engine/app/copy-page.css +59 -1
- package/src/engine/app/global.css +3157 -3
- package/src/engine/app/layout.tsx +56 -1
- package/src/engine/app/llms-file/route.ts +87 -0
- package/src/engine/app/llms-full-file/route.ts +62 -0
- package/src/engine/app/md-file/[...slug]/route.ts +409 -0
- package/src/engine/app/page.tsx +45 -0
- package/src/engine/app/robots.txt/route.ts +63 -0
- package/src/engine/app/rss-file/[...slug]/route.ts +169 -0
- package/src/engine/app/sitemap.xml/route.ts +82 -0
- package/src/engine/components/assistant.tsx +16 -5
- package/src/engine/components/changelog-filters.tsx +114 -0
- package/src/engine/components/code-group.tsx +383 -0
- package/src/engine/components/color.tsx +118 -0
- package/src/engine/components/expandable.tsx +77 -0
- package/src/engine/components/icon.tsx +136 -0
- package/src/engine/components/image-zoom-fallback.tsx +147 -0
- package/src/engine/components/image.tsx +111 -0
- package/src/engine/components/manual-api-playground.tsx +154 -0
- package/src/engine/components/mermaid.tsx +142 -0
- package/src/engine/components/openapi-toc-sync.tsx +59 -0
- package/src/engine/components/openapi.tsx +1682 -0
- package/src/engine/components/page-feedback.tsx +153 -0
- package/src/engine/components/product-switcher.tsx +27 -3
- package/src/engine/components/prompt.tsx +90 -0
- package/src/engine/components/providers.tsx +1 -6
- package/src/engine/components/search.tsx +4 -0
- package/src/engine/components/sidebar-links.tsx +13 -15
- package/src/engine/components/synced-tabs.tsx +57 -0
- package/src/engine/components/toc-examples.tsx +110 -0
- package/src/engine/components/view.tsx +344 -0
- package/src/engine/generated/redirects.ts +3 -0
- package/src/engine/lib/changelog.ts +246 -0
- package/src/engine/lib/layout.shared.ts +30 -2
- package/src/engine/lib/llms.ts +444 -0
- package/src/engine/lib/navigation-normalize.mjs +481 -412
- package/src/engine/lib/navigation-normalize.ts +261 -54
- package/src/engine/lib/redirects.ts +194 -0
- package/src/engine/lib/source.ts +107 -4
- package/src/engine/lib/velu.ts +368 -2
- package/src/engine/mdx-components.tsx +648 -0
- package/src/engine/middleware.ts +66 -0
- package/src/engine/public/icons/cursor-dark.svg +12 -0
- package/src/engine/public/icons/cursor-light.svg +12 -0
- package/src/engine/source.config.ts +98 -1
- package/src/engine/src/components/PageTitle.astro +16 -5
- package/src/engine/src/lib/velu.ts +11 -3
- package/src/navigation-normalize.ts +252 -54
- package/src/themes.ts +6 -6
- package/src/validate.ts +119 -6
- package/src/engine/app/(docs)/[[...slug]]/layout.tsx +0 -87
- package/src/engine/app/(docs)/[[...slug]]/page.tsx +0 -146
|
@@ -0,0 +1,926 @@
|
|
|
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 pageDataRecord = (page.data as unknown) as Record<string, unknown>;
|
|
560
|
+
const MDX = pageDataRecord.body as any;
|
|
561
|
+
if (typeof MDX !== 'function') notFound();
|
|
562
|
+
const sourceMarkdown = await loadMarkdownForSlug(pageSlug, locale, hasI18n);
|
|
563
|
+
const dataMarkdown = typeof pageDataRecord.processedMarkdown === 'string'
|
|
564
|
+
? String(pageDataRecord.processedMarkdown)
|
|
565
|
+
: undefined;
|
|
566
|
+
const effectiveMarkdown = sourceMarkdown ?? dataMarkdown;
|
|
567
|
+
const frontmatter = parseFrontmatterMap(effectiveMarkdown);
|
|
568
|
+
const apiConfig = getApiConfig();
|
|
569
|
+
const hasExplicitApiRendering = typeof effectiveMarkdown === 'string'
|
|
570
|
+
&& (/<(?:APIPlayground|ApiPlayground|OpenAPI)\b/.test(effectiveMarkdown)
|
|
571
|
+
|| /@scalar\/api-reference|id=['"]api-reference['"]|createApiReference/.test(effectiveMarkdown));
|
|
572
|
+
const configuredMdxServers = (apiConfig.mdxServers && apiConfig.mdxServers.length > 0)
|
|
573
|
+
? apiConfig.mdxServers
|
|
574
|
+
: [
|
|
575
|
+
...(apiConfig.mdxServer ? [apiConfig.mdxServer] : []),
|
|
576
|
+
...(apiConfig.baseUrl ? [apiConfig.baseUrl] : []),
|
|
577
|
+
];
|
|
578
|
+
const parsedApiFrontmatter = parseApiFrontmatter(
|
|
579
|
+
frontmatter.api ?? (typeof pageDataRecord.api === 'string' ? pageDataRecord.api : undefined),
|
|
580
|
+
configuredMdxServers,
|
|
581
|
+
);
|
|
582
|
+
const parsedOpenApiFrontmatter = parseOpenApiFrontmatter(
|
|
583
|
+
frontmatter.openapi ?? (typeof pageDataRecord.openapi === 'string' ? pageDataRecord.openapi : undefined),
|
|
584
|
+
apiConfig.defaultOpenApiSpec,
|
|
585
|
+
);
|
|
586
|
+
const parsedOpenApiSchemaFrontmatter = parseOpenApiSchemaFrontmatter(
|
|
587
|
+
frontmatter['openapi-schema'] ?? (typeof pageDataRecord['openapi-schema'] === 'string' ? pageDataRecord['openapi-schema'] : undefined),
|
|
588
|
+
apiConfig.defaultOpenApiSpec,
|
|
589
|
+
);
|
|
590
|
+
const playgroundDisplay = normalizePlaygroundDisplay(frontmatter.playground, apiConfig.playgroundDisplay);
|
|
591
|
+
const proxyUrl = apiConfig.playgroundProxyEnabled ? '/api/proxy' : '';
|
|
592
|
+
const authMethod = normalizeAuthMethod(frontmatter.authMethod, apiConfig.authMethod);
|
|
593
|
+
const inlineApiTitle = typeof pageDataRecord.title === 'string' && pageDataRecord.title.trim().length > 0
|
|
594
|
+
? pageDataRecord.title
|
|
595
|
+
: (frontmatter.title ?? pageSlug);
|
|
596
|
+
const inlineApiDescription = typeof pageDataRecord.description === 'string'
|
|
597
|
+
? pageDataRecord.description
|
|
598
|
+
: frontmatter.description;
|
|
599
|
+
const inlineApiDoc = parsedApiFrontmatter
|
|
600
|
+
? buildInlineApiDoc(parsedApiFrontmatter, inlineApiTitle, inlineApiDescription, authMethod, apiConfig.authName)
|
|
601
|
+
: null;
|
|
602
|
+
const hasPanelExamples = typeof effectiveMarkdown === 'string'
|
|
603
|
+
&& /<(?:Panel|RequestExample|ResponseExample)(?:\s|>)/.test(effectiveMarkdown);
|
|
604
|
+
const parsedChangelog = parseChangelogFromMarkdown(effectiveMarkdown);
|
|
605
|
+
const hasChangelog = parsedChangelog.updates.length > 0;
|
|
606
|
+
const hasChangelogTags = parsedChangelog.tags.length > 0;
|
|
607
|
+
const isDeprecatedPage = parseFrontmatterBoolean(effectiveMarkdown, 'deprecated')
|
|
608
|
+
|| frontmatter.status?.trim().toLowerCase() === 'deprecated'
|
|
609
|
+
|| (pageDataRecord.deprecated === true)
|
|
610
|
+
|| String((pageDataRecord.status ?? '')).trim().toLowerCase() === 'deprecated';
|
|
611
|
+
const showRssButton = hasChangelog && parseFrontmatterBoolean(effectiveMarkdown, 'rss');
|
|
612
|
+
const sourcePageUrl = (page as unknown as { url?: string }).url;
|
|
613
|
+
const fallbackPath = `/${(resolvedParams.slug ?? []).join('/')}`.replace(/\/{2,}/g, '/');
|
|
614
|
+
const pageUrl = (typeof sourcePageUrl === 'string' && sourcePageUrl.trim())
|
|
615
|
+
? sourcePageUrl
|
|
616
|
+
: (fallbackPath === '' ? '/' : fallbackPath);
|
|
617
|
+
const rssHref = `${pageUrl.replace(/\/$/, '') || ''}/rss.xml`;
|
|
618
|
+
const shouldReplaceTocWithApiExample = !hasExplicitApiRendering && Boolean(inlineApiDoc) && playgroundDisplay === 'interactive';
|
|
619
|
+
const shouldShowOpenApiExampleInToc = !hasExplicitApiRendering && !parsedApiFrontmatter && Boolean(parsedOpenApiFrontmatter);
|
|
620
|
+
const hasApiTocRail = shouldReplaceTocWithApiExample || shouldShowOpenApiExampleInToc;
|
|
621
|
+
const apiTocHeader = hasApiTocRail ? (
|
|
622
|
+
<div className="velu-api-toc-rail">
|
|
623
|
+
<div id="velu-api-toc-rail-host" />
|
|
624
|
+
</div>
|
|
625
|
+
) : undefined;
|
|
626
|
+
const pageToc = pageDataRecord.toc as any;
|
|
627
|
+
const pageFull = typeof pageDataRecord.full === 'boolean' ? pageDataRecord.full : undefined;
|
|
628
|
+
const toc = hasChangelog ? parsedChangelog.toc : pageToc;
|
|
629
|
+
const tableOfContentHeader = apiTocHeader ?? (hasPanelExamples ? <div className="velu-toc-panel-rail" /> : undefined);
|
|
630
|
+
const orderedPages = hasI18n ? source.getPages(locale) : source.getPages();
|
|
631
|
+
const currentPageUrl = (typeof sourcePageUrl === 'string' && sourcePageUrl.trim())
|
|
632
|
+
? sourcePageUrl
|
|
633
|
+
: pageUrl;
|
|
634
|
+
const currentIndex = orderedPages.findIndex((entry) => entry.url === currentPageUrl);
|
|
635
|
+
const previousPage = currentIndex > 0 ? orderedPages[currentIndex - 1] : undefined;
|
|
636
|
+
const nextPage = currentIndex >= 0 && currentIndex < orderedPages.length - 1 ? orderedPages[currentIndex + 1] : undefined;
|
|
637
|
+
|
|
638
|
+
// Build pagefind filter attributes
|
|
639
|
+
const metaAttrs: string[] = [`title:${page.data.title}`];
|
|
640
|
+
const filterAttrs: string[] = [];
|
|
641
|
+
if (hasI18n) {
|
|
642
|
+
metaAttrs.push(`language:${filterLocale}`);
|
|
643
|
+
filterAttrs.push(`language:${filterLocale}`);
|
|
644
|
+
}
|
|
645
|
+
if (version) {
|
|
646
|
+
metaAttrs.push(`version:${version}`);
|
|
647
|
+
filterAttrs.push(`version:${version}`);
|
|
648
|
+
}
|
|
649
|
+
if (product) {
|
|
650
|
+
metaAttrs.push(`product:${product}`);
|
|
651
|
+
filterAttrs.push(`product:${product}`);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return (
|
|
655
|
+
<DocsPage
|
|
656
|
+
toc={toc}
|
|
657
|
+
full={hasChangelog ? false : (hasApiTocRail ? false : pageFull)}
|
|
658
|
+
tableOfContent={tableOfContentHeader ? { header: tableOfContentHeader } : undefined}
|
|
659
|
+
footer={{ enabled: false }}
|
|
660
|
+
>
|
|
661
|
+
<div
|
|
662
|
+
data-pagefind-body
|
|
663
|
+
data-pagefind-meta={metaAttrs.join(',')}
|
|
664
|
+
data-pagefind-filter={filterAttrs.length > 0 ? filterAttrs.join(',') : undefined}
|
|
665
|
+
>
|
|
666
|
+
<TocExamples />
|
|
667
|
+
<OpenApiTocSync enabled={hasApiTocRail} />
|
|
668
|
+
{hasChangelogTags ? <ChangelogFilters tags={parsedChangelog.tags} /> : null}
|
|
669
|
+
<VeluImageZoomFallback />
|
|
670
|
+
<div className="velu-title-row">
|
|
671
|
+
<div className="velu-title-main">
|
|
672
|
+
<DocsTitle>{page.data.title}</DocsTitle>
|
|
673
|
+
{isDeprecatedPage ? <span className="velu-pill velu-pill-deprecated velu-page-deprecated-badge">Deprecated</span> : null}
|
|
674
|
+
</div>
|
|
675
|
+
<div className="velu-title-actions">
|
|
676
|
+
<CopyPageButton />
|
|
677
|
+
{showRssButton ? (
|
|
678
|
+
<a className="velu-rss-button" href={rssHref} aria-label="Subscribe to this changelog RSS feed">
|
|
679
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
680
|
+
<path d="M4 11a9 9 0 0 1 9 9" />
|
|
681
|
+
<path d="M4 4a16 16 0 0 1 16 16" />
|
|
682
|
+
<circle cx="5" cy="19" r="1.5" />
|
|
683
|
+
</svg>
|
|
684
|
+
</a>
|
|
685
|
+
) : null}
|
|
686
|
+
</div>
|
|
687
|
+
</div>
|
|
688
|
+
{page.data.description ? <DocsDescription>{page.data.description}</DocsDescription> : null}
|
|
689
|
+
<DocsBody>
|
|
690
|
+
{!hasExplicitApiRendering && inlineApiDoc && playgroundDisplay === 'interactive' ? (
|
|
691
|
+
<VeluOpenAPI
|
|
692
|
+
className="velu-api-playground"
|
|
693
|
+
inlineDocument={inlineApiDoc.document}
|
|
694
|
+
inlineDocumentId={`velu-inline-${pageUrl.replace(/[^a-z0-9]+/gi, '-').toLowerCase() || 'api'}`}
|
|
695
|
+
endpoint={inlineApiDoc.endpoint}
|
|
696
|
+
method={inlineApiDoc.method}
|
|
697
|
+
proxyUrl={proxyUrl}
|
|
698
|
+
exampleLanguages={apiConfig.exampleLanguages}
|
|
699
|
+
exampleAutogenerate={apiConfig.exampleAutogenerate}
|
|
700
|
+
layout="playground"
|
|
701
|
+
showTitle={false}
|
|
702
|
+
showDescription={false}
|
|
703
|
+
/>
|
|
704
|
+
) : null}
|
|
705
|
+
{!hasExplicitApiRendering && parsedApiFrontmatter && playgroundDisplay === 'simple' ? (
|
|
706
|
+
<VeluManualApiPlayground
|
|
707
|
+
method={parsedApiFrontmatter.method}
|
|
708
|
+
url={parsedApiFrontmatter.url}
|
|
709
|
+
display="simple"
|
|
710
|
+
/>
|
|
711
|
+
) : null}
|
|
712
|
+
{!hasExplicitApiRendering && !parsedApiFrontmatter && parsedOpenApiFrontmatter ? (
|
|
713
|
+
<VeluOpenAPI
|
|
714
|
+
className="velu-api-playground"
|
|
715
|
+
schemaSource={parsedOpenApiFrontmatter.spec}
|
|
716
|
+
endpoint={parsedOpenApiFrontmatter.endpoint}
|
|
717
|
+
method={parsedOpenApiFrontmatter.method}
|
|
718
|
+
proxyUrl={proxyUrl}
|
|
719
|
+
exampleLanguages={apiConfig.exampleLanguages}
|
|
720
|
+
exampleAutogenerate={apiConfig.exampleAutogenerate}
|
|
721
|
+
layout="playground"
|
|
722
|
+
showTitle={false}
|
|
723
|
+
showDescription={false}
|
|
724
|
+
/>
|
|
725
|
+
) : null}
|
|
726
|
+
{!hasExplicitApiRendering && !parsedApiFrontmatter && !parsedOpenApiFrontmatter && parsedOpenApiSchemaFrontmatter ? (
|
|
727
|
+
<VeluOpenAPISchema
|
|
728
|
+
className="velu-openapi-schema-wrapper"
|
|
729
|
+
schemaSource={parsedOpenApiSchemaFrontmatter.spec}
|
|
730
|
+
schema={parsedOpenApiSchemaFrontmatter.schema}
|
|
731
|
+
/>
|
|
732
|
+
) : null}
|
|
733
|
+
<MDX
|
|
734
|
+
components={getMDXComponents({
|
|
735
|
+
a: createRelativeLink(source, page),
|
|
736
|
+
})}
|
|
737
|
+
/>
|
|
738
|
+
</DocsBody>
|
|
739
|
+
<section className="velu-page-feedback-wrap" aria-label="Page feedback">
|
|
740
|
+
<PageFeedback />
|
|
741
|
+
{(previousPage || nextPage) ? (
|
|
742
|
+
<div className={['velu-page-nav-grid', previousPage && nextPage ? 'velu-page-nav-grid-two' : 'velu-page-nav-grid-one'].join(' ')}>
|
|
743
|
+
{previousPage ? (
|
|
744
|
+
<a href={previousPage.url} className="velu-page-nav-card">
|
|
745
|
+
<p className="velu-page-nav-title">{previousPage.data.title}</p>
|
|
746
|
+
<p className="velu-page-nav-meta">
|
|
747
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg>
|
|
748
|
+
<span>{previousPage.data.description ?? 'Previous'}</span>
|
|
749
|
+
</p>
|
|
750
|
+
</a>
|
|
751
|
+
) : null}
|
|
752
|
+
{nextPage ? (
|
|
753
|
+
<a href={nextPage.url} className="velu-page-nav-card velu-page-nav-card-next">
|
|
754
|
+
<p className="velu-page-nav-title">{nextPage.data.title}</p>
|
|
755
|
+
<p className="velu-page-nav-meta velu-page-nav-meta-next">
|
|
756
|
+
<span>{nextPage.data.description ?? 'Next'}</span>
|
|
757
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18l6-6-6-6" /></svg>
|
|
758
|
+
</p>
|
|
759
|
+
</a>
|
|
760
|
+
) : null}
|
|
761
|
+
</div>
|
|
762
|
+
) : null}
|
|
763
|
+
</section>
|
|
764
|
+
</div>
|
|
765
|
+
<footer className="velu-footer">
|
|
766
|
+
Powered by <a href="https://getvelu.com" target="_blank" rel="noopener noreferrer">Velu</a>
|
|
767
|
+
</footer>
|
|
768
|
+
</DocsPage>
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
export async function generateStaticParams() {
|
|
773
|
+
const generated = source.generateParams('slug') as Array<{ slug?: string[] }>;
|
|
774
|
+
const seen = new Set<string>();
|
|
775
|
+
|
|
776
|
+
const nonRoot = generated.filter((entry) => {
|
|
777
|
+
const slug = entry.slug ?? [];
|
|
778
|
+
if (slug.length === 0) return false;
|
|
779
|
+
const key = slug.join('/');
|
|
780
|
+
if (seen.has(key)) return false;
|
|
781
|
+
seen.add(key);
|
|
782
|
+
return true;
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
return nonRoot;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
789
|
+
const resolvedParams = await params;
|
|
790
|
+
const { locale, pageSlug } = resolveLocaleSlug(resolvedParams.slug);
|
|
791
|
+
const hasI18n = getLanguages().length > 1;
|
|
792
|
+
const seo = getSeoConfig();
|
|
793
|
+
const siteName = getSiteName();
|
|
794
|
+
const siteOrigin = getSiteOrigin();
|
|
795
|
+
|
|
796
|
+
const page = hasI18n ? source.getPage(pageSlug, locale) : source.getPage(pageSlug);
|
|
797
|
+
|
|
798
|
+
if (!page) notFound();
|
|
799
|
+
|
|
800
|
+
const sourceMarkdown = await loadMarkdownForSlug(pageSlug, locale, hasI18n);
|
|
801
|
+
const pageDataRecord = (page.data as unknown) as Record<string, unknown>;
|
|
802
|
+
const dataMarkdown = typeof pageDataRecord.processedMarkdown === 'string'
|
|
803
|
+
? String(pageDataRecord.processedMarkdown)
|
|
804
|
+
: undefined;
|
|
805
|
+
const effectiveMarkdown = sourceMarkdown ?? dataMarkdown;
|
|
806
|
+
const frontmatterData = parseFrontmatterData(effectiveMarkdown);
|
|
807
|
+
const pageTopLevelMetatags = extractFrontmatterMetatags(frontmatterData);
|
|
808
|
+
const pageNestedMetatags = normalizeMetatagMap(frontmatterData.metatags);
|
|
809
|
+
const mergedMetatags: Record<string, string> = {
|
|
810
|
+
...seo.metatags,
|
|
811
|
+
...pageTopLevelMetatags,
|
|
812
|
+
...pageNestedMetatags,
|
|
813
|
+
};
|
|
814
|
+
const sourcePageUrl = (page as unknown as { url?: string }).url;
|
|
815
|
+
const fallbackPath = `/${(resolvedParams.slug ?? []).join('/')}`.replace(/\/{2,}/g, '/');
|
|
816
|
+
const pageUrl = (typeof sourcePageUrl === 'string' && sourcePageUrl.trim())
|
|
817
|
+
? sourcePageUrl
|
|
818
|
+
: (fallbackPath === '' ? '/' : fallbackPath);
|
|
819
|
+
|
|
820
|
+
const canonical = resolveCanonicalUrl(siteOrigin, pageUrl, mergedMetatags.canonical);
|
|
821
|
+
const keywords = parseKeywords(frontmatterData, mergedMetatags.keywords);
|
|
822
|
+
const robotsFromMetatag = parseRobotsDirectives(mergedMetatags.robots);
|
|
823
|
+
const noindex = normalizeBoolean(frontmatterData.noindex) === true
|
|
824
|
+
|| normalizeBoolean(frontmatterData.hidden) === true
|
|
825
|
+
|| parseFrontmatterBoolean(effectiveMarkdown, 'noindex')
|
|
826
|
+
|| parseFrontmatterBoolean(effectiveMarkdown, 'hidden')
|
|
827
|
+
|| (mergedMetatags.robots ?? '').toLowerCase().includes('noindex')
|
|
828
|
+
|| (mergedMetatags.robots ?? '').toLowerCase().includes('none');
|
|
829
|
+
const titleOverride = mergedMetatags.title?.trim();
|
|
830
|
+
const resolvedTitle = titleOverride || `${page.data.title} - ${siteName}`;
|
|
831
|
+
const resolvedDescription = (mergedMetatags.description?.trim() || page.data.description || '').trim() || undefined;
|
|
832
|
+
const generatedSocialImage = buildGeneratedOgImagePath(pageUrl);
|
|
833
|
+
const fallbackImage = mergedMetatags['og:image']
|
|
834
|
+
|| mergedMetatags['twitter:image']
|
|
835
|
+
|| generatedSocialImage;
|
|
836
|
+
const openGraphImagesRaw = normalizeImageList(mergedMetatags['og:image'] ?? fallbackImage);
|
|
837
|
+
const twitterImagesRaw = normalizeImageList(mergedMetatags['twitter:image'] ?? fallbackImage);
|
|
838
|
+
const ogImageWidth = mergedMetatags['og:image:width'] || '1200';
|
|
839
|
+
const ogImageHeight = mergedMetatags['og:image:height'] || '630';
|
|
840
|
+
const twitterImageWidth = mergedMetatags['twitter:image:width'] || '1200';
|
|
841
|
+
const twitterImageHeight = mergedMetatags['twitter:image:height'] || '630';
|
|
842
|
+
const openGraphImages = openGraphImagesRaw.map((entry) => ({
|
|
843
|
+
url: toAbsoluteMetaUrl(siteOrigin, entry),
|
|
844
|
+
width: Number(ogImageWidth),
|
|
845
|
+
height: Number(ogImageHeight),
|
|
846
|
+
}));
|
|
847
|
+
const twitterImages = twitterImagesRaw.map((entry) => toAbsoluteMetaUrl(siteOrigin, entry));
|
|
848
|
+
const openGraph: Metadata['openGraph'] = {
|
|
849
|
+
type: (mergedMetatags['og:type'] as any) || 'website',
|
|
850
|
+
siteName: mergedMetatags['og:site_name'] || siteName,
|
|
851
|
+
title: mergedMetatags['og:title'] || resolvedTitle,
|
|
852
|
+
...(resolvedDescription ? { description: mergedMetatags['og:description'] || resolvedDescription } : {}),
|
|
853
|
+
url: mergedMetatags['og:url'] ? toAbsoluteMetaUrl(siteOrigin, mergedMetatags['og:url']) : canonical,
|
|
854
|
+
...(mergedMetatags['og:locale'] ? { locale: mergedMetatags['og:locale'] } : {}),
|
|
855
|
+
...(openGraphImages.length > 0 ? { images: openGraphImages as any } : {}),
|
|
856
|
+
};
|
|
857
|
+
const twitter: Metadata['twitter'] = {
|
|
858
|
+
card: (mergedMetatags['twitter:card'] as any) || 'summary_large_image',
|
|
859
|
+
title: mergedMetatags['twitter:title'] || resolvedTitle,
|
|
860
|
+
...(resolvedDescription ? { description: mergedMetatags['twitter:description'] || resolvedDescription } : {}),
|
|
861
|
+
...(mergedMetatags['twitter:site'] ? { site: mergedMetatags['twitter:site'] } : {}),
|
|
862
|
+
...(mergedMetatags['twitter:creator'] ? { creator: mergedMetatags['twitter:creator'] } : {}),
|
|
863
|
+
...(twitterImages.length > 0 ? { images: twitterImages } : {}),
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
const handledTags = new Set([
|
|
867
|
+
'canonical',
|
|
868
|
+
'keywords',
|
|
869
|
+
'robots',
|
|
870
|
+
'application-name',
|
|
871
|
+
'apple-mobile-web-app-title',
|
|
872
|
+
'apple-mobile-web-app-capable',
|
|
873
|
+
'apple-mobile-web-app-status-bar-style',
|
|
874
|
+
'msapplication-TileColor',
|
|
875
|
+
'og:title',
|
|
876
|
+
'og:description',
|
|
877
|
+
'og:url',
|
|
878
|
+
'og:site_name',
|
|
879
|
+
'og:type',
|
|
880
|
+
'og:locale',
|
|
881
|
+
'og:image',
|
|
882
|
+
'twitter:card',
|
|
883
|
+
'twitter:title',
|
|
884
|
+
'twitter:description',
|
|
885
|
+
'twitter:site',
|
|
886
|
+
'twitter:creator',
|
|
887
|
+
'twitter:image',
|
|
888
|
+
'og:image:width',
|
|
889
|
+
'og:image:height',
|
|
890
|
+
'twitter:image:width',
|
|
891
|
+
'twitter:image:height',
|
|
892
|
+
'title',
|
|
893
|
+
'description',
|
|
894
|
+
'generator',
|
|
895
|
+
]);
|
|
896
|
+
const otherMetatags = Object.fromEntries(
|
|
897
|
+
Object.entries(mergedMetatags).filter(([key]) => !handledTags.has(key)),
|
|
898
|
+
);
|
|
899
|
+
if (openGraphImages.length > 0) {
|
|
900
|
+
otherMetatags['og:image:width'] = ogImageWidth;
|
|
901
|
+
otherMetatags['og:image:height'] = ogImageHeight;
|
|
902
|
+
}
|
|
903
|
+
if (twitterImages.length > 0) {
|
|
904
|
+
otherMetatags['twitter:image:width'] = twitterImageWidth;
|
|
905
|
+
otherMetatags['twitter:image:height'] = twitterImageHeight;
|
|
906
|
+
}
|
|
907
|
+
if (noindex) {
|
|
908
|
+
otherMetatags.noindex = 'true';
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
return {
|
|
912
|
+
...(titleOverride ? { title: { absolute: titleOverride } } : { title: page.data.title }),
|
|
913
|
+
...(resolvedDescription ? { description: resolvedDescription } : {}),
|
|
914
|
+
...(keywords && keywords.length > 0 ? { keywords } : {}),
|
|
915
|
+
alternates: { canonical },
|
|
916
|
+
openGraph,
|
|
917
|
+
twitter,
|
|
918
|
+
...(mergedMetatags.generator ? { generator: mergedMetatags.generator } : {}),
|
|
919
|
+
...(noindex
|
|
920
|
+
? { robots: { index: false, follow: false } }
|
|
921
|
+
: robotsFromMetatag
|
|
922
|
+
? { robots: robotsFromMetatag }
|
|
923
|
+
: { robots: { index: true, follow: true } }),
|
|
924
|
+
...(Object.keys(otherMetatags).length > 0 ? { other: otherMetatags } : {}),
|
|
925
|
+
};
|
|
926
|
+
}
|