@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
@@ -1,38 +1,444 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
+ import { normalizeConfigNavigation } from './navigation-normalize';
4
+ const PRIMARY_CONFIG_NAME = 'docs.json';
5
+ const LEGACY_CONFIG_NAME = 'velu.json';
6
+
7
+ function resolveConfigPath(cwd: string): string {
8
+ const primary = resolve(cwd, PRIMARY_CONFIG_NAME);
9
+ if (existsSync(primary)) return primary;
10
+ return resolve(cwd, LEGACY_CONFIG_NAME);
11
+ }
3
12
 
4
13
  interface VeluTab {
5
14
  tab: string;
15
+ slug?: string;
6
16
  href?: string;
17
+ pages?: Array<string | VeluSeparator | VeluLink>;
18
+ groups?: VeluGroup[];
19
+ }
20
+
21
+ interface VeluTabMenuItem {
22
+ item: string;
23
+ pages?: Array<string | VeluSeparator | VeluLink>;
24
+ }
25
+
26
+ interface VeluSeparator {
27
+ separator: string;
28
+ }
29
+
30
+ interface VeluLink {
31
+ href: string;
32
+ label: string;
33
+ }
34
+
35
+ interface VeluGroup {
36
+ group: string;
37
+ slug?: string;
38
+ pages: Array<string | VeluGroup | VeluSeparator | VeluLink>;
39
+ }
40
+
41
+ interface VeluAnchor {
42
+ anchor: string;
43
+ href?: string;
44
+ icon?: string;
45
+ iconType?: string;
46
+ color?: {
47
+ light: string;
48
+ dark: string;
49
+ };
50
+ tabs?: VeluTab[];
51
+ hidden?: boolean;
52
+ }
53
+
54
+ interface VeluGlobalTab {
55
+ tab: string;
56
+ href: string;
57
+ icon?: string;
58
+ }
59
+
60
+ interface VeluLanguageNav {
61
+ language: string;
62
+ tabs: VeluTab[];
63
+ }
64
+
65
+ interface VeluProductNav {
66
+ product: string;
67
+ description?: string;
68
+ icon?: string;
69
+ iconType?: string;
70
+ hidden?: boolean;
71
+ href?: string;
72
+ }
73
+
74
+ interface VeluVersionNav {
75
+ version: string;
76
+ default?: boolean;
77
+ hidden?: boolean;
78
+ href?: string;
79
+ }
80
+
81
+ type VeluApiAuthMethod = 'bearer' | 'basic' | 'key' | 'none';
82
+
83
+ interface VeluApiConfig {
84
+ baseUrl?: string;
85
+ playground?: {
86
+ mode?: string;
87
+ display?: string;
88
+ proxy?: boolean;
89
+ };
90
+ examples?: {
91
+ languages?: string[];
92
+ defaults?: 'required' | 'all';
93
+ prefill?: boolean;
94
+ autogenerate?: boolean;
95
+ };
96
+ mdx?: {
97
+ server?: string | string[];
98
+ auth?: {
99
+ method?: VeluApiAuthMethod | string;
100
+ name?: string;
101
+ };
102
+ };
103
+ }
104
+
105
+ interface VeluSeoConfig {
106
+ metatags?: Record<string, unknown>;
107
+ indexing?: 'navigable' | 'all' | string;
108
+ }
109
+
110
+ interface VeluThemeAsset {
111
+ light?: string;
112
+ dark?: string;
113
+ href?: string;
114
+ }
115
+
116
+ export interface VeluProductOption {
117
+ product: string;
118
+ slug: string;
119
+ description?: string;
120
+ icon?: string;
121
+ iconType?: string;
122
+ tabSlugs: string[];
123
+ defaultPath: string;
124
+ }
125
+
126
+ export interface VeluVersionOption {
127
+ version: string;
128
+ slug: string;
129
+ isDefault: boolean;
130
+ tabSlugs: string[];
131
+ defaultPath: string;
7
132
  }
8
133
 
9
134
  interface VeluConfig {
135
+ name?: string;
136
+ title?: string;
137
+ favicon?: string | VeluThemeAsset;
138
+ logo?: string | VeluThemeAsset;
139
+ colors?: {
140
+ primary?: string;
141
+ light?: string;
142
+ dark?: string;
143
+ };
144
+ icons?: {
145
+ library?: string;
146
+ };
10
147
  appearance?: 'system' | 'light' | 'dark';
148
+ languages?: string[];
149
+ openapi?: string | string[] | Record<string, unknown>;
150
+ asyncapi?: string | string[] | Record<string, unknown>;
151
+ api?: VeluApiConfig;
152
+ seo?: VeluSeoConfig;
11
153
  navigation: {
12
- tabs: VeluTab[];
154
+ tabs?: VeluTab[];
155
+ languages?: VeluLanguageNav[];
156
+ products?: VeluProductNav[];
157
+ versions?: VeluVersionNav[];
158
+ anchors?: VeluAnchor[];
159
+ global?: {
160
+ anchors?: VeluAnchor[];
161
+ tabs?: VeluGlobalTab[];
162
+ };
13
163
  };
14
164
  }
15
165
 
16
166
  let cachedConfig: VeluConfig | null = null;
167
+ let cachedRawConfig: Record<string, unknown> | null = null;
17
168
 
18
169
  function loadVeluConfig(): VeluConfig {
19
170
  if (cachedConfig) return cachedConfig;
20
- const configPath = resolve(process.cwd(), 'velu.json');
171
+ const configPath = resolveConfigPath(process.cwd());
21
172
  const raw = readFileSync(configPath, 'utf-8');
22
- cachedConfig = JSON.parse(raw) as VeluConfig;
173
+ cachedConfig = normalizeConfigNavigation(JSON.parse(raw)) as VeluConfig;
23
174
  return cachedConfig;
24
175
  }
25
176
 
177
+ function loadRawConfig(): Record<string, unknown> {
178
+ if (cachedRawConfig) return cachedRawConfig;
179
+ const configPath = resolveConfigPath(process.cwd());
180
+ const raw = readFileSync(configPath, 'utf-8');
181
+ const parsed = JSON.parse(raw);
182
+ cachedRawConfig = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
183
+ ? parsed as Record<string, unknown>
184
+ : {};
185
+ return cachedRawConfig;
186
+ }
187
+
188
+ function isGroup(item: unknown): item is VeluGroup {
189
+ return typeof item === 'object' && item !== null && 'group' in item;
190
+ }
191
+
192
+ function isRecord(value: unknown): value is Record<string, unknown> {
193
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
194
+ }
195
+
196
+ function isTabMenuItem(value: unknown): value is VeluTabMenuItem {
197
+ return isRecord(value) && typeof value.item === 'string';
198
+ }
199
+
200
+ function slugify(input: string, fallback: string): string {
201
+ const slug = input
202
+ .toLowerCase()
203
+ .trim()
204
+ .replace(/[^a-z0-9]+/g, '-')
205
+ .replace(/^-+|-+$/g, '');
206
+ return slug || fallback;
207
+ }
208
+
209
+ function pageBasename(page: string): string {
210
+ const parts = page.split('/').filter(Boolean);
211
+ return parts[parts.length - 1] ?? page;
212
+ }
213
+
214
+ function findFirstPageInGroup(group: VeluGroup): string | undefined {
215
+ for (const item of group.pages) {
216
+ if (typeof item === 'string') return item;
217
+ if (isGroup(item)) {
218
+ const nested = findFirstPageInGroup(item);
219
+ if (nested) return nested;
220
+ }
221
+ }
222
+ return undefined;
223
+ }
224
+
225
+ function findFirstPageInTab(tab: VeluTab): string | undefined {
226
+ if (tab.pages) {
227
+ for (const item of tab.pages) {
228
+ if (typeof item === 'string') return item;
229
+ }
230
+ }
231
+ if (tab.groups) {
232
+ for (const group of tab.groups) {
233
+ const nested = findFirstPageInGroup(group);
234
+ if (nested) return nested;
235
+ }
236
+ }
237
+ return undefined;
238
+ }
239
+
240
+ function parseVersionParts(version: string): number[] {
241
+ const parts = version.match(/\d+/g);
242
+ return parts ? parts.map((n) => Number(n)) : [];
243
+ }
244
+
245
+ function compareVersionParts(a: number[], b: number[]): number {
246
+ const len = Math.max(a.length, b.length);
247
+ for (let i = 0; i < len; i += 1) {
248
+ const av = a[i] ?? 0;
249
+ const bv = b[i] ?? 0;
250
+ if (av !== bv) return av - bv;
251
+ }
252
+ return 0;
253
+ }
254
+
26
255
  export function getExternalTabs(): Array<{ label: string; href: string }> {
27
256
  const config = loadVeluConfig();
28
257
  const tabs = config.navigation?.tabs ?? [];
258
+ const globalTabs = config.navigation?.global?.tabs ?? [];
29
259
 
30
- return tabs
260
+ const tabLinks = tabs
31
261
  .filter((tab): tab is VeluTab & { href: string } => typeof tab.href === 'string' && tab.href.length > 0)
32
262
  .map((tab) => ({
33
263
  label: tab.tab,
34
264
  href: tab.href,
35
265
  }));
266
+
267
+ const globalLinks = globalTabs
268
+ .filter((tab): tab is VeluGlobalTab => typeof tab.href === 'string' && tab.href.length > 0)
269
+ .map((tab) => ({
270
+ label: tab.tab,
271
+ href: tab.href,
272
+ }));
273
+
274
+ return [...tabLinks, ...globalLinks];
275
+ }
276
+
277
+ export function getNavbarAnchors(): VeluAnchor[] {
278
+ const config = loadVeluConfig();
279
+ return (config.navigation.anchors ?? []).filter(
280
+ (a): a is VeluAnchor & { href: string } => typeof a.href === 'string' && a.href.length > 0 && !a.hidden
281
+ );
282
+ }
283
+
284
+ export function getGlobalAnchors(): VeluAnchor[] {
285
+ const config = loadVeluConfig();
286
+ return (config.navigation.global?.anchors ?? []).filter(
287
+ (a): a is VeluAnchor & { href: string } => typeof a.href === 'string' && a.href.length > 0 && !a.hidden
288
+ );
289
+ }
290
+
291
+ export interface VeluTabMenuDefinition {
292
+ tab: string;
293
+ items: Array<{
294
+ item: string;
295
+ pages: string[];
296
+ }>;
297
+ }
298
+
299
+ function extractMenuItems(menuValue: unknown): VeluTabMenuDefinition['items'] {
300
+ if (!Array.isArray(menuValue)) return [];
301
+ const out: VeluTabMenuDefinition['items'] = [];
302
+ for (const entry of menuValue) {
303
+ if (!isTabMenuItem(entry)) continue;
304
+ const pages = Array.isArray(entry.pages)
305
+ ? entry.pages.filter((page): page is string => typeof page === 'string' && page.trim().length > 0)
306
+ : [];
307
+ out.push({ item: entry.item, pages });
308
+ }
309
+ return out;
310
+ }
311
+
312
+ function collectTabMenus(section: unknown, out: VeluTabMenuDefinition[]): void {
313
+ if (!isRecord(section)) return;
314
+
315
+ const tabs = Array.isArray(section.tabs) ? section.tabs : [];
316
+ for (const tabCandidate of tabs) {
317
+ if (!isRecord(tabCandidate)) continue;
318
+ if (typeof tabCandidate.tab === 'string') {
319
+ const items = extractMenuItems(tabCandidate.menu);
320
+ if (items.length > 0) out.push({ tab: tabCandidate.tab, items });
321
+ }
322
+ collectTabMenus(tabCandidate, out);
323
+ }
324
+
325
+ const nestedKeys: Array<'dropdowns' | 'products' | 'versions' | 'anchors'> = [
326
+ 'dropdowns',
327
+ 'products',
328
+ 'versions',
329
+ 'anchors',
330
+ ];
331
+ for (const key of nestedKeys) {
332
+ const list = Array.isArray(section[key]) ? section[key] : [];
333
+ for (const entry of list) collectTabMenus(entry, out);
334
+ }
335
+ }
336
+
337
+ export function getTabMenuDefinitions(): VeluTabMenuDefinition[] {
338
+ const raw = loadRawConfig();
339
+ const navigation = isRecord(raw.navigation) ? raw.navigation : {};
340
+ const out: VeluTabMenuDefinition[] = [];
341
+ collectTabMenus(navigation, out);
342
+ return out;
343
+ }
344
+
345
+ export function getLanguages(): string[] {
346
+ const config = loadVeluConfig();
347
+ // Prefer navigation.languages codes, fall back to top-level languages
348
+ if (config.navigation.languages && config.navigation.languages.length > 0) {
349
+ return config.navigation.languages.map((l) => l.language);
350
+ }
351
+ return config.languages ?? [];
352
+ }
353
+
354
+ export function getProductOptions(): VeluProductOption[] {
355
+ const config = loadVeluConfig();
356
+ const products = (config.navigation.products ?? []).filter((p) => !p.hidden);
357
+ if (products.length === 0) return [];
358
+
359
+ const allTabs = config.navigation.tabs ?? [];
360
+
361
+ return products.map((product, index) => {
362
+ const prefix = slugify(product.product, `product-${index + 1}`);
363
+ const productTabs = allTabs.filter((tab) => {
364
+ const slug = tab.slug ?? '';
365
+ return slug === prefix || slug.startsWith(`${prefix}/`);
366
+ });
367
+
368
+ const tabSlugs = productTabs
369
+ .map((tab) => tab.slug)
370
+ .filter((slug): slug is string => typeof slug === 'string' && slug.length > 0);
371
+
372
+ const firstTab = productTabs[0];
373
+ const firstPage = firstTab ? findFirstPageInTab(firstTab) : undefined;
374
+ const defaultPath = firstTab
375
+ ? (firstPage ? `/${firstTab.slug}/${pageBasename(firstPage)}` : `/${firstTab.slug}`)
376
+ : (product.href ?? '/');
377
+
378
+ return {
379
+ product: product.product,
380
+ slug: prefix,
381
+ description: product.description,
382
+ icon: product.icon,
383
+ iconType: product.iconType,
384
+ tabSlugs,
385
+ defaultPath,
386
+ };
387
+ });
388
+ }
389
+
390
+ export function getVersionOptions(): VeluVersionOption[] {
391
+ const config = loadVeluConfig();
392
+ const versions = (config.navigation.versions ?? []).filter((v) => !v.hidden);
393
+ if (versions.length === 0) return [];
394
+
395
+ const allTabs = config.navigation.tabs ?? [];
396
+
397
+ const baseEntries = versions.map((version, index) => {
398
+ const prefix = slugify(version.version, `version-${index + 1}`);
399
+ const versionTabs = allTabs.filter((tab) => {
400
+ const slug = tab.slug ?? '';
401
+ return slug === prefix || slug.startsWith(`${prefix}/`);
402
+ });
403
+
404
+ const tabSlugs = versionTabs
405
+ .map((tab) => tab.slug)
406
+ .filter((slug): slug is string => typeof slug === 'string' && slug.length > 0);
407
+
408
+ const firstTab = versionTabs[0];
409
+ const firstPage = firstTab ? findFirstPageInTab(firstTab) : undefined;
410
+ const defaultPath = firstTab
411
+ ? (firstPage ? `/${firstTab.slug}/${pageBasename(firstPage)}` : `/${firstTab.slug}`)
412
+ : (version.href ?? '/');
413
+
414
+ return {
415
+ version: version.version,
416
+ slug: prefix,
417
+ explicitDefault: version.default === true,
418
+ versionParts: parseVersionParts(version.version),
419
+ tabSlugs,
420
+ defaultPath,
421
+ order: index,
422
+ };
423
+ });
424
+
425
+ const explicitDefault = baseEntries.find((entry) => entry.explicitDefault);
426
+ const latest = explicitDefault
427
+ ?? baseEntries
428
+ .slice()
429
+ .sort((a, b) => {
430
+ const cmp = compareVersionParts(b.versionParts, a.versionParts);
431
+ if (cmp !== 0) return cmp;
432
+ return a.order - b.order;
433
+ })[0];
434
+
435
+ return baseEntries.map((entry) => ({
436
+ version: entry.version,
437
+ slug: entry.slug,
438
+ isDefault: entry.slug === latest?.slug,
439
+ tabSlugs: entry.tabSlugs,
440
+ defaultPath: entry.defaultPath,
441
+ }));
36
442
  }
37
443
 
38
444
  export function getAppearance(): 'system' | 'light' | 'dark' {
@@ -40,3 +446,227 @@ export function getAppearance(): 'system' | 'light' | 'dark' {
40
446
  if (appearance === 'light' || appearance === 'dark') return appearance;
41
447
  return 'system';
42
448
  }
449
+
450
+ export type VeluIconLibrary = 'fontawesome' | 'lucide' | 'tabler';
451
+
452
+ export function getIconLibrary(): VeluIconLibrary {
453
+ const raw = loadVeluConfig().icons?.library;
454
+ if (raw === 'lucide' || raw === 'tabler' || raw === 'fontawesome') return raw;
455
+ return 'fontawesome';
456
+ }
457
+
458
+ type PlaygroundDisplayMode = 'interactive' | 'simple' | 'none' | 'auth';
459
+
460
+ export interface VeluResolvedApiConfig {
461
+ baseUrl?: string;
462
+ mdxServer?: string;
463
+ mdxServers?: string[];
464
+ authMethod: VeluApiAuthMethod;
465
+ authName?: string;
466
+ playgroundDisplay: PlaygroundDisplayMode;
467
+ playgroundProxyEnabled: boolean;
468
+ exampleLanguages?: string[];
469
+ exampleDefaults: 'required' | 'all';
470
+ examplePrefill: boolean;
471
+ exampleAutogenerate: boolean;
472
+ defaultOpenApiSpec?: string;
473
+ defaultAsyncApiSpec?: string;
474
+ }
475
+
476
+ export interface VeluResolvedSeoConfig {
477
+ metatags: Record<string, string>;
478
+ indexing: 'navigable' | 'all';
479
+ }
480
+
481
+ function normalizePlaygroundDisplay(api: VeluApiConfig | undefined): PlaygroundDisplayMode {
482
+ const display = api?.playground?.display;
483
+ if (display === 'interactive' || display === 'simple' || display === 'none') return display;
484
+ if (display === 'auth') return 'none';
485
+
486
+ const mode = api?.playground?.mode;
487
+ if (mode === 'hide' || mode === 'none') return 'none';
488
+ return 'interactive';
489
+ }
490
+
491
+ function normalizeAuthMethod(method: unknown): VeluApiAuthMethod {
492
+ if (method === 'bearer' || method === 'basic' || method === 'key' || method === 'none') return method;
493
+ return 'none';
494
+ }
495
+
496
+ function extractOpenApiSource(openapi: VeluConfig['openapi']): string | string[] | undefined {
497
+ if (typeof openapi === 'string' || Array.isArray(openapi)) return openapi;
498
+ if (openapi && typeof openapi === 'object') {
499
+ const source = (openapi as Record<string, unknown>).source;
500
+ if (typeof source === 'string' || Array.isArray(source)) return source as string | string[];
501
+ }
502
+ return undefined;
503
+ }
504
+
505
+ function resolveDefaultOpenApiSpec(openapi: VeluConfig['openapi']): string | undefined {
506
+ const source = extractOpenApiSource(openapi);
507
+ if (typeof source === 'string' && source.trim()) return source.trim();
508
+ if (Array.isArray(source)) {
509
+ const first = source.find((entry) => typeof entry === 'string' && entry.trim().length > 0);
510
+ return typeof first === 'string' ? first.trim() : undefined;
511
+ }
512
+ return undefined;
513
+ }
514
+
515
+ function normalizeExampleLanguages(value: unknown): string[] | undefined {
516
+ if (!Array.isArray(value)) return undefined;
517
+ const normalized = value
518
+ .filter((entry): entry is string => typeof entry === 'string')
519
+ .map((entry) => entry.trim())
520
+ .filter((entry) => entry.length > 0);
521
+ return normalized.length > 0 ? normalized : undefined;
522
+ }
523
+
524
+ function normalizeMdxServers(value: unknown): string[] | undefined {
525
+ const rawValues = Array.isArray(value) ? value : (typeof value === 'string' ? [value] : []);
526
+ const normalized = rawValues
527
+ .filter((entry): entry is string => typeof entry === 'string')
528
+ .map((entry) => entry.trim())
529
+ .filter((entry) => entry.length > 0);
530
+ return normalized.length > 0 ? normalized : undefined;
531
+ }
532
+
533
+ function normalizeSeoMetatags(value: unknown): Record<string, string> {
534
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
535
+ const output: Record<string, string> = {};
536
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
537
+ const tag = key.trim();
538
+ if (!tag) continue;
539
+ if (typeof raw === 'string') {
540
+ const normalized = raw.trim();
541
+ if (normalized) output[tag] = normalized;
542
+ continue;
543
+ }
544
+ if (typeof raw === 'number' || typeof raw === 'boolean') {
545
+ output[tag] = String(raw);
546
+ }
547
+ }
548
+ return output;
549
+ }
550
+
551
+ function normalizeAssetPath(value: unknown): string | undefined {
552
+ if (typeof value !== 'string') return undefined;
553
+ const trimmed = value.trim();
554
+ return trimmed.length > 0 ? trimmed : undefined;
555
+ }
556
+
557
+ function normalizeThemeAsset(value: unknown): VeluThemeAsset {
558
+ if (typeof value === 'string') {
559
+ const asset = normalizeAssetPath(value);
560
+ return asset ? { light: asset, dark: asset } : {};
561
+ }
562
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
563
+ const record = value as Record<string, unknown>;
564
+ const light = normalizeAssetPath(record.light);
565
+ const dark = normalizeAssetPath(record.dark);
566
+ const any = normalizeAssetPath(record.default);
567
+ const href = normalizeAssetPath(record.href);
568
+ return {
569
+ ...(light ? { light } : {}),
570
+ ...(dark ? { dark } : {}),
571
+ ...(!light && any ? { light: any } : {}),
572
+ ...(!dark && any ? { dark: any } : {}),
573
+ ...(href ? { href } : {}),
574
+ };
575
+ }
576
+
577
+ function extractOrigin(value: string | undefined): string | undefined {
578
+ if (!value) return undefined;
579
+ const trimmed = value.trim();
580
+ if (!trimmed) return undefined;
581
+ try {
582
+ return new URL(trimmed).origin;
583
+ } catch {
584
+ return undefined;
585
+ }
586
+ }
587
+
588
+ export function getApiConfig(): VeluResolvedApiConfig {
589
+ const config = loadVeluConfig();
590
+ const api = config.api;
591
+ const auth = api?.mdx?.auth;
592
+ const examples = api?.examples;
593
+ const playgroundDisplay = normalizePlaygroundDisplay(api);
594
+ const staticExportBuild = process.env.VELU_STATIC_EXPORT === '1';
595
+ const mdxServers = normalizeMdxServers(api?.mdx?.server);
596
+
597
+ return {
598
+ baseUrl: typeof api?.baseUrl === 'string' && api.baseUrl.trim() ? api.baseUrl.trim() : undefined,
599
+ mdxServer: mdxServers?.[0],
600
+ mdxServers,
601
+ authMethod: normalizeAuthMethod(auth?.method),
602
+ authName: typeof auth?.name === 'string' && auth.name.trim() ? auth.name.trim() : undefined,
603
+ playgroundDisplay,
604
+ // Next static export cannot include runtime route handlers such as /api/proxy.
605
+ // Disable proxy automatically for static export builds.
606
+ playgroundProxyEnabled: !staticExportBuild && api?.playground?.proxy !== false,
607
+ exampleLanguages: normalizeExampleLanguages(examples?.languages),
608
+ exampleDefaults: examples?.defaults === 'required' ? 'required' : 'all',
609
+ examplePrefill: examples?.prefill === true,
610
+ exampleAutogenerate: examples?.autogenerate !== false,
611
+ defaultOpenApiSpec: resolveDefaultOpenApiSpec(config.openapi),
612
+ defaultAsyncApiSpec: resolveDefaultOpenApiSpec(config.asyncapi),
613
+ };
614
+ }
615
+
616
+ export function getSeoConfig(): VeluResolvedSeoConfig {
617
+ const config = loadVeluConfig();
618
+ const seo = config.seo;
619
+ const indexing: 'navigable' | 'all' = seo?.indexing === 'all' ? 'all' : 'navigable';
620
+ return {
621
+ metatags: normalizeSeoMetatags(seo?.metatags),
622
+ indexing,
623
+ };
624
+ }
625
+
626
+ export function getSiteName(): string {
627
+ const config = loadVeluConfig();
628
+ const fromName = normalizeAssetPath(config.name);
629
+ if (fromName) return fromName;
630
+ const fromTitle = normalizeAssetPath(config.title);
631
+ if (fromTitle) return fromTitle;
632
+ return 'Velu Docs';
633
+ }
634
+
635
+ export function getSiteFavicon(): string | undefined {
636
+ const config = loadVeluConfig();
637
+ const asset = normalizeThemeAsset(config.favicon);
638
+ return asset.light ?? asset.dark;
639
+ }
640
+
641
+ export function getSiteLogoAsset(): VeluThemeAsset {
642
+ const config = loadVeluConfig();
643
+ return normalizeThemeAsset(config.logo);
644
+ }
645
+
646
+ export function getSitePrimaryColor(): string | undefined {
647
+ const config = loadVeluConfig();
648
+ const colors = config.colors;
649
+ if (!colors) return undefined;
650
+ return normalizeAssetPath(colors.primary) ?? normalizeAssetPath(colors.light) ?? normalizeAssetPath(colors.dark);
651
+ }
652
+
653
+ export function getSiteOrigin(): string {
654
+ const seo = getSeoConfig();
655
+ const envCandidates = [
656
+ process.env.VELU_SITE_URL,
657
+ process.env.NEXT_PUBLIC_SITE_URL,
658
+ process.env.SITE_URL,
659
+ ];
660
+ for (const candidate of envCandidates) {
661
+ const origin = extractOrigin(candidate);
662
+ if (origin) return origin;
663
+ }
664
+
665
+ const canonicalOrigin = extractOrigin(seo.metatags.canonical);
666
+ if (canonicalOrigin) return canonicalOrigin;
667
+
668
+ const ogOrigin = extractOrigin(seo.metatags['og:url']);
669
+ if (ogOrigin) return ogOrigin;
670
+
671
+ return 'http://localhost:4321';
672
+ }