@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.
Files changed (60) hide show
  1. package/package.json +15 -6
  2. package/schema/velu.schema.json +1251 -115
  3. package/src/build.ts +1121 -304
  4. package/src/cli.ts +90 -26
  5. package/src/engine/_server.mjs +1684 -277
  6. package/src/engine/app/(docs)/[...slug]/layout.tsx +371 -0
  7. package/src/engine/app/(docs)/[...slug]/page.tsx +926 -0
  8. package/src/engine/app/api/proxy/route.ts +23 -0
  9. package/src/engine/app/copy-page.css +59 -1
  10. package/src/engine/app/global.css +3157 -3
  11. package/src/engine/app/layout.tsx +56 -1
  12. package/src/engine/app/llms-file/route.ts +87 -0
  13. package/src/engine/app/llms-full-file/route.ts +62 -0
  14. package/src/engine/app/md-file/[...slug]/route.ts +409 -0
  15. package/src/engine/app/page.tsx +45 -0
  16. package/src/engine/app/robots.txt/route.ts +63 -0
  17. package/src/engine/app/rss-file/[...slug]/route.ts +169 -0
  18. package/src/engine/app/sitemap.xml/route.ts +82 -0
  19. package/src/engine/components/assistant.tsx +16 -5
  20. package/src/engine/components/changelog-filters.tsx +114 -0
  21. package/src/engine/components/code-group.tsx +383 -0
  22. package/src/engine/components/color.tsx +118 -0
  23. package/src/engine/components/expandable.tsx +77 -0
  24. package/src/engine/components/icon.tsx +136 -0
  25. package/src/engine/components/image-zoom-fallback.tsx +147 -0
  26. package/src/engine/components/image.tsx +111 -0
  27. package/src/engine/components/manual-api-playground.tsx +154 -0
  28. package/src/engine/components/mermaid.tsx +142 -0
  29. package/src/engine/components/openapi-toc-sync.tsx +59 -0
  30. package/src/engine/components/openapi.tsx +1682 -0
  31. package/src/engine/components/page-feedback.tsx +153 -0
  32. package/src/engine/components/product-switcher.tsx +27 -3
  33. package/src/engine/components/prompt.tsx +90 -0
  34. package/src/engine/components/providers.tsx +1 -6
  35. package/src/engine/components/search.tsx +4 -0
  36. package/src/engine/components/sidebar-links.tsx +13 -15
  37. package/src/engine/components/synced-tabs.tsx +57 -0
  38. package/src/engine/components/toc-examples.tsx +110 -0
  39. package/src/engine/components/view.tsx +344 -0
  40. package/src/engine/generated/redirects.ts +3 -0
  41. package/src/engine/lib/changelog.ts +246 -0
  42. package/src/engine/lib/layout.shared.ts +30 -2
  43. package/src/engine/lib/llms.ts +444 -0
  44. package/src/engine/lib/navigation-normalize.mjs +481 -412
  45. package/src/engine/lib/navigation-normalize.ts +261 -54
  46. package/src/engine/lib/redirects.ts +194 -0
  47. package/src/engine/lib/source.ts +107 -4
  48. package/src/engine/lib/velu.ts +368 -2
  49. package/src/engine/mdx-components.tsx +648 -0
  50. package/src/engine/middleware.ts +66 -0
  51. package/src/engine/public/icons/cursor-dark.svg +12 -0
  52. package/src/engine/public/icons/cursor-light.svg +12 -0
  53. package/src/engine/source.config.ts +98 -1
  54. package/src/engine/src/components/PageTitle.astro +16 -5
  55. package/src/engine/src/lib/velu.ts +11 -3
  56. package/src/navigation-normalize.ts +252 -54
  57. package/src/themes.ts +6 -6
  58. package/src/validate.ts +119 -6
  59. package/src/engine/app/(docs)/[[...slug]]/layout.tsx +0 -87
  60. package/src/engine/app/(docs)/[[...slug]]/page.tsx +0 -146
@@ -1,380 +1,1787 @@
1
- import { spawn } from 'node:child_process';
2
- import { createRequire } from 'node:module';
3
- import { watch } from 'node:fs';
4
- import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
- import { dirname, extname, join, resolve } from 'node:path';
6
- import { normalizeConfigNavigation } from './lib/navigation-normalize.mjs';
7
-
1
+ import { spawn } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import { watch } from 'node:fs';
4
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { dirname, extname, join, relative, resolve } from 'node:path';
6
+ import { normalizeConfigNavigation } from './lib/navigation-normalize.mjs';
7
+
8
8
  const require = createRequire(import.meta.url);
9
9
  const nextBinPath = require.resolve('next/dist/bin/next');
10
+ const { parse: parseYaml } = require('yaml');
11
+
12
+ // ── Docs directory (passed via env var from CLI) ────────────────────────────
13
+ const docsDir = process.env.VELU_DOCS_DIR || resolve('..');
14
+ const contentDir = resolve('content', 'docs');
15
+ const publicDir = resolve('public');
16
+ const PRIMARY_CONFIG_NAME = 'docs.json';
17
+ const LEGACY_CONFIG_NAME = 'velu.json';
18
+ const SOURCE_MIRROR_DIR = 'velu-imports';
19
+ const sourceMirrorDir = resolve(SOURCE_MIRROR_DIR);
20
+ const STATIC_EXTENSIONS = new Set([
21
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico',
22
+ '.mp4', '.webm',
23
+ '.mp3', '.wav',
24
+ '.json', '.yaml', '.yml',
25
+ '.css',
26
+ '.js',
27
+ '.woff', '.woff2', '.ttf', '.eot',
28
+ '.pdf', '.txt',
29
+ '.xml', '.csv',
30
+ '.zip',
31
+ ]);
32
+ const SOURCE_MIRROR_EXTENSIONS = new Set([
33
+ '.md', '.mdx', '.jsx', '.js', '.tsx', '.ts',
34
+ '.json', '.yaml', '.yml', '.css',
35
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico',
36
+ '.woff', '.woff2', '.ttf', '.eot',
37
+ '.mp4', '.webm', '.mp3', '.wav',
38
+ '.pdf', '.txt', '.xml', '.csv', '.zip',
39
+ ]);
40
+ const IMPORT_REWRITE_EXTENSIONS = new Set(['.md', '.mdx', '.jsx', '.js', '.tsx', '.ts']);
41
+
42
+ function resolveConfigPath() {
43
+ const primary = join(docsDir, PRIMARY_CONFIG_NAME);
44
+ if (existsSync(primary)) return primary;
45
+ return join(docsDir, LEGACY_CONFIG_NAME);
46
+ }
47
+
48
+ function isStaticAsset(filename) {
49
+ const ext = extname(filename).toLowerCase();
50
+ return STATIC_EXTENSIONS.has(ext);
51
+ }
52
+
53
+ function copyStaticAssets() {
54
+ function walk(dir) {
55
+ const entries = readdirSync(dir, { withFileTypes: true });
56
+ for (const entry of entries) {
57
+ if (entry.name.startsWith('.')) continue;
58
+ if (entry.name === 'node_modules') continue;
59
+ const srcPath = join(dir, entry.name);
60
+ if (entry.isDirectory()) {
61
+ walk(srcPath);
62
+ continue;
63
+ }
64
+ if (!isStaticAsset(srcPath)) continue;
65
+ const rel = relative(docsDir, srcPath);
66
+ const destPath = join(publicDir, rel);
67
+ mkdirSync(dirname(destPath), { recursive: true });
68
+ copyFileSync(srcPath, destPath);
69
+ }
70
+ }
71
+
72
+ mkdirSync(publicDir, { recursive: true });
73
+ walk(docsDir);
74
+ }
10
75
 
11
- // ── Docs directory (passed via env var from CLI) ────────────────────────────
12
- const docsDir = process.env.VELU_DOCS_DIR || resolve('..');
13
- const contentDir = resolve('content', 'docs');
76
+ function escapeXml(value) {
77
+ return String(value ?? '')
78
+ .replace(/&/g, '&')
79
+ .replace(/</g, '&lt;')
80
+ .replace(/>/g, '&gt;')
81
+ .replace(/"/g, '&quot;')
82
+ .replace(/'/g, '&apos;');
83
+ }
14
84
 
15
- function loadConfig() {
16
- const raw = readFileSync(join(docsDir, 'velu.json'), 'utf-8');
17
- return normalizeConfigNavigation(JSON.parse(raw));
85
+ function normalizeHexColor(value, fallback = '#10b981') {
86
+ const text = String(value ?? '').trim();
87
+ if (/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(text)) return text;
88
+ return fallback;
18
89
  }
19
90
 
20
- function pageBasename(page) {
21
- return page.split('/').pop();
91
+ function hexToRgb(hex) {
92
+ const normalized = normalizeHexColor(hex).replace('#', '');
93
+ const raw = normalized.length === 3
94
+ ? normalized.split('').map((ch) => `${ch}${ch}`).join('')
95
+ : normalized;
96
+ const int = Number.parseInt(raw, 16);
97
+ return {
98
+ r: (int >> 16) & 255,
99
+ g: (int >> 8) & 255,
100
+ b: int & 255,
101
+ };
22
102
  }
23
103
 
24
- function pageLabelFromSlug(slug) {
25
- const last = slug.split('/').pop() || slug;
26
- return last.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
104
+ function rgbaFromHex(hex, alpha) {
105
+ const { r, g, b } = hexToRgb(hex);
106
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
27
107
  }
28
108
 
29
- function isSeparator(item) {
30
- return typeof item === 'object' && item !== null && 'separator' in item;
109
+ function humanizeSegment(value) {
110
+ const cleaned = String(value ?? '')
111
+ .trim()
112
+ .replace(/[-_]+/g, ' ')
113
+ .replace(/\s+/g, ' ');
114
+ if (!cleaned) return 'Documentation';
115
+ return cleaned.replace(/\b\w/g, (ch) => ch.toUpperCase());
31
116
  }
32
117
 
33
- function isLink(item) {
34
- return typeof item === 'object' && item !== null && 'href' in item && 'label' in item;
118
+ function parseFrontmatterData(markdown) {
119
+ if (!markdown || typeof markdown !== 'string') return {};
120
+ const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
121
+ if (!match) return {};
122
+ try {
123
+ const parsed = parseYaml(match[1]);
124
+ return parsed && typeof parsed === 'object' ? parsed : {};
125
+ } catch {
126
+ return {};
127
+ }
35
128
  }
36
129
 
37
- function isGroup(item) {
38
- return typeof item === 'object' && item !== null && 'group' in item;
130
+ function ensureLeadingSlash(value) {
131
+ const text = String(value ?? '').trim();
132
+ if (!text) return '';
133
+ return text.startsWith('/') ? text : `/${text}`;
39
134
  }
40
135
 
41
- function metaEntry(item) {
42
- if (typeof item === 'string') return item;
43
- if (isSeparator(item)) return `---${item.separator}---`;
44
- if (isLink(item)) {
45
- return item.icon
46
- ? `[${item.icon}][${item.label}](${item.href})`
47
- : `[${item.label}](${item.href})`;
136
+ function resolveLogoReference(config) {
137
+ const logo = config?.logo;
138
+ let rawLogo = null;
139
+ if (typeof logo === 'string') {
140
+ rawLogo = logo.trim();
141
+ } else if (logo && typeof logo === 'object') {
142
+ if (typeof logo.dark === 'string' && logo.dark.trim()) rawLogo = logo.dark.trim();
143
+ else if (typeof logo.light === 'string' && logo.light.trim()) rawLogo = logo.light.trim();
144
+ else if (typeof logo.default === 'string' && logo.default.trim()) rawLogo = logo.default.trim();
48
145
  }
49
- return String(item);
146
+
147
+ if (!rawLogo) return null;
148
+ if (/^https?:\/\//i.test(rawLogo)) return rawLogo;
149
+
150
+ const localPath = join(docsDir, rawLogo.replace(/^\/+/, ''));
151
+ if (!existsSync(localPath)) return ensureLeadingSlash(rawLogo);
152
+
153
+ const ext = extname(localPath).toLowerCase();
154
+ const mime = ext === '.svg'
155
+ ? 'image/svg+xml'
156
+ : ext === '.png'
157
+ ? 'image/png'
158
+ : ext === '.jpg' || ext === '.jpeg'
159
+ ? 'image/jpeg'
160
+ : ext === '.webp'
161
+ ? 'image/webp'
162
+ : null;
163
+ if (!mime) return ensureLeadingSlash(rawLogo);
164
+
165
+ const encoded = readFileSync(localPath).toString('base64');
166
+ return `data:${mime};base64,${encoded}`;
50
167
  }
51
168
 
52
- function buildArtifacts(config) {
53
- const pageMap = [];
54
- const metaFiles = [];
55
- const rootTabs = (config.navigation?.tabs || []).filter((tab) => !tab.href);
56
- const rootPages = rootTabs.map((tab) => tab.slug);
57
- let firstPage = 'quickstart';
58
- let hasFirstPage = false;
169
+ function resolvePrimaryColor(config) {
170
+ const colors = config?.colors;
171
+ if (!colors || typeof colors !== 'object') return '#10b981';
172
+ return normalizeHexColor(colors.primary || colors.light || colors.dark || '#10b981');
173
+ }
59
174
 
60
- function trackFirstPage(dest) {
61
- if (!hasFirstPage) {
62
- firstPage = dest;
63
- hasFirstPage = true;
64
- }
175
+ function resolveSiteName(config) {
176
+ if (typeof config?.name === 'string' && config.name.trim()) return config.name.trim();
177
+ if (typeof config?.title === 'string' && config.title.trim()) return config.title.trim();
178
+ return 'Documentation';
179
+ }
180
+
181
+ function readMetaInfo(pathSegments) {
182
+ const metaPath = join(contentDir, ...pathSegments, 'meta.json');
183
+ if (!existsSync(metaPath)) return null;
184
+ try {
185
+ const parsed = JSON.parse(readFileSync(metaPath, 'utf-8'));
186
+ if (!parsed || typeof parsed !== 'object') return null;
187
+ const title = typeof parsed.title === 'string' && parsed.title.trim() ? parsed.title.trim() : null;
188
+ const root = parsed.root === true;
189
+ return { title, root };
190
+ } catch {
191
+ return null;
65
192
  }
193
+ }
66
194
 
67
- function addGroup(group, parentDir) {
68
- const groupDir = `${parentDir}/${group.slug}`;
69
- const pages = [];
195
+ function resolveSectionLabel(routeSegments, siteName) {
196
+ if (!Array.isArray(routeSegments) || routeSegments.length === 0) return siteName;
70
197
 
71
- for (const item of group.pages || []) {
72
- if (typeof item === 'string') {
73
- const basename = pageBasename(item);
74
- const dest = `${groupDir}/${basename}`;
75
- pageMap.push({ src: item, dest });
76
- pages.push(basename);
77
- trackFirstPage(dest);
78
- } else if (isGroup(item)) {
79
- addGroup(item, groupDir);
80
- pages.push(item.hidden ? `!${item.slug}` : item.slug);
81
- } else if (isSeparator(item)) {
82
- pages.push(`---${item.separator}---`);
83
- } else if (isLink(item)) {
84
- pages.push(
85
- item.icon
86
- ? `[${item.icon}][${item.label}](${item.href})`
87
- : `[${item.label}](${item.href})`
88
- );
89
- }
90
- }
198
+ const firstMeta = readMetaInfo([routeSegments[0]]);
199
+ if (routeSegments.length > 1 && firstMeta?.root === true) {
200
+ const secondMeta = readMetaInfo([routeSegments[0], routeSegments[1]]);
201
+ if (secondMeta?.title) return secondMeta.title;
202
+ return humanizeSegment(routeSegments[1]);
203
+ }
91
204
 
92
- const groupMeta = {
93
- title: group.group,
94
- pages,
95
- defaultOpen: group.expanded !== false,
96
- };
205
+ if (firstMeta?.title) return firstMeta.title;
206
+ return humanizeSegment(routeSegments[0]);
207
+ }
97
208
 
98
- if (group.icon) groupMeta.icon = group.icon;
99
- if (group.description) groupMeta.description = group.description;
209
+ function normalizeRoutePathFromContentFile(relativePath) {
210
+ const normalized = toPosixPath(relativePath).replace(/\.(md|mdx)$/i, '');
211
+ const trimmed = normalized.replace(/^\/+/, '');
212
+ if (!trimmed || trimmed === 'index') return '/';
213
+ if (trimmed.endsWith('/index')) return `/${trimmed.slice(0, -('/index'.length))}`;
214
+ return `/${trimmed}`;
215
+ }
100
216
 
101
- metaFiles.push({ dir: groupDir, data: groupMeta });
217
+ function splitTitleLines(value, maxChars = 34, maxLines = 2) {
218
+ const words = String(value ?? '').trim().split(/\s+/).filter(Boolean);
219
+ if (words.length === 0) return ['Documentation'];
220
+ const lines = [];
221
+ let current = '';
222
+ let index = 0;
223
+
224
+ while (index < words.length) {
225
+ const word = words[index];
226
+ const next = current ? `${current} ${word}` : word;
227
+ if (next.length <= maxChars || !current) {
228
+ current = next;
229
+ index += 1;
230
+ continue;
231
+ }
232
+ lines.push(current);
233
+ current = '';
234
+ if (lines.length === maxLines - 1) break;
102
235
  }
103
236
 
104
- for (const tab of rootTabs) {
105
- const tabPages = [];
237
+ const tail = [...(current ? [current] : []), ...words.slice(index)].join(' ').trim();
238
+ if (tail) lines.push(tail);
106
239
 
107
- for (const group of tab.groups || []) {
108
- addGroup(group, tab.slug);
109
- tabPages.push(group.hidden ? `!${group.slug}` : group.slug);
110
- }
240
+ if (lines.length > maxLines) {
241
+ lines.length = maxLines;
242
+ }
243
+ if (lines.length === maxLines && lines[maxLines - 1].length > maxChars) {
244
+ lines[maxLines - 1] = `${lines[maxLines - 1].slice(0, Math.max(1, maxChars - 1)).trim()}…`;
245
+ }
111
246
 
112
- for (const item of tab.pages || []) {
113
- if (typeof item === 'string') {
114
- const basename = pageBasename(item);
115
- const dest = `${tab.slug}/${basename}`;
116
- pageMap.push({ src: item, dest });
117
- tabPages.push(basename);
118
- trackFirstPage(dest);
119
- } else {
120
- tabPages.push(metaEntry(item));
247
+ return lines;
248
+ }
249
+
250
+ function collectContentMarkdownFiles() {
251
+ const files = [];
252
+ function walk(dir) {
253
+ if (!existsSync(dir)) return;
254
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
255
+ if (entry.name.startsWith('.')) continue;
256
+ const fullPath = join(dir, entry.name);
257
+ if (entry.isDirectory()) {
258
+ walk(fullPath);
259
+ continue;
121
260
  }
261
+ if (!entry.isFile()) continue;
262
+ if (!/\.(md|mdx)$/i.test(entry.name)) continue;
263
+ files.push(fullPath);
122
264
  }
265
+ }
266
+ walk(contentDir);
267
+ return files;
268
+ }
123
269
 
124
- const tabMeta = {
125
- title: tab.tab,
126
- root: true,
127
- pages: tabPages,
128
- };
270
+ function toOgOutputPath(routePath) {
271
+ const normalized = routePath === '/' ? 'index' : routePath.replace(/^\/+/, '');
272
+ return join(publicDir, 'og', `${normalized}.svg`);
273
+ }
274
+
275
+ function buildOgSvg({ title, section, description, logoHref, primaryColor }) {
276
+ const gradientAccent = rgbaFromHex(primaryColor, 0.48);
277
+ const gradientGlow = rgbaFromHex(primaryColor, 0.14);
278
+ const titleLines = splitTitleLines(title, 34, 2);
279
+ const titleBaseY = 470 - ((titleLines.length - 1) * 74);
280
+ const titleTs = titleLines
281
+ .map((line, idx) => `<tspan x="70" y="${titleBaseY + (idx * 78)}">${escapeXml(line)}</tspan>`)
282
+ .join('');
283
+ const descriptionText = description ? `<text x="70" y="570" fill="#A9B3C2" font-size="34" font-family="Inter, Segoe UI, Arial, sans-serif">${escapeXml(description)}</text>` : '';
284
+ const logoNode = logoHref
285
+ ? `<image href="${escapeXml(logoHref)}" x="70" y="52" width="220" height="56" preserveAspectRatio="xMinYMid meet" />`
286
+ : '';
287
+
288
+ return [
289
+ '<?xml version="1.0" encoding="UTF-8"?>',
290
+ '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">',
291
+ ' <defs>',
292
+ ' <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">',
293
+ ' <stop offset="0%" stop-color="#070C11" />',
294
+ ' <stop offset="48%" stop-color="#0A1218" />',
295
+ ` <stop offset="100%" stop-color="${escapeXml(gradientGlow)}" />`,
296
+ ' </linearGradient>',
297
+ ' <radialGradient id="accent" cx="78%" cy="18%" r="70%">',
298
+ ` <stop offset="0%" stop-color="${escapeXml(gradientAccent)}" />`,
299
+ ' <stop offset="60%" stop-color="rgba(0,0,0,0)" />',
300
+ ' </radialGradient>',
301
+ ' </defs>',
302
+ ' <rect width="1200" height="630" fill="url(#bg)" />',
303
+ ' <rect width="1200" height="630" fill="url(#accent)" />',
304
+ ` ${logoNode}`,
305
+ ` <text x="70" y="365" fill="#D4DBE6" font-size="44" font-family="Inter, Segoe UI, Arial, sans-serif">${escapeXml(section)}</text>`,
306
+ ` <text x="70" y="${titleBaseY}" fill="#FFFFFF" font-size="76" font-weight="700" font-family="Inter, Segoe UI, Arial, sans-serif">${titleTs}</text>`,
307
+ ` ${descriptionText}`,
308
+ '</svg>',
309
+ '',
310
+ ].join('\n');
311
+ }
129
312
 
130
- if (tab.icon) tabMeta.icon = tab.icon;
313
+ function generateOgImages(config) {
314
+ const ogRootDir = join(publicDir, 'og');
315
+ rmSync(ogRootDir, { recursive: true, force: true });
316
+ mkdirSync(ogRootDir, { recursive: true });
317
+
318
+ const files = collectContentMarkdownFiles();
319
+ const siteName = resolveSiteName(config);
320
+ const logoHref = resolveLogoReference(config);
321
+ const primaryColor = resolvePrimaryColor(config);
322
+
323
+ for (const filePath of files) {
324
+ const relPath = toPosixPath(relative(contentDir, filePath));
325
+ const routePath = normalizeRoutePathFromContentFile(relPath);
326
+ const markdown = readFileSync(filePath, 'utf-8');
327
+ const frontmatter = parseFrontmatterData(markdown);
328
+ const routeSegments = routePath === '/' ? [] : routePath.replace(/^\/+/, '').split('/').filter(Boolean);
329
+ const fallbackTitle = humanizeSegment(routeSegments[routeSegments.length - 1] || 'overview');
330
+ const title = typeof frontmatter.title === 'string' && frontmatter.title.trim()
331
+ ? frontmatter.title.trim()
332
+ : fallbackTitle;
333
+ const rawDescription = typeof frontmatter.description === 'string' ? frontmatter.description.trim() : '';
334
+ const description = rawDescription.length > 120 ? `${rawDescription.slice(0, 119).trim()}…` : rawDescription;
335
+ const section = resolveSectionLabel(routeSegments, siteName);
336
+ const svg = buildOgSvg({
337
+ title,
338
+ section,
339
+ description,
340
+ logoHref,
341
+ primaryColor,
342
+ });
131
343
 
132
- metaFiles.push({ dir: tab.slug, data: tabMeta });
344
+ const outPath = toOgOutputPath(routePath);
345
+ mkdirSync(dirname(outPath), { recursive: true });
346
+ writeFileSync(outPath, svg, 'utf-8');
133
347
  }
348
+ }
134
349
 
135
- if (rootPages.length > 0) {
136
- metaFiles.push({ dir: '', data: { pages: rootPages } });
350
+ function toPosixPath(value) {
351
+ return value.replace(/\\/g, '/');
352
+ }
353
+
354
+ function isInsideDocsRoot(targetPath) {
355
+ const relPath = relative(docsDir, targetPath);
356
+ if (!relPath) return true;
357
+ if (relPath.startsWith('..')) return false;
358
+ if (/^[a-zA-Z]:/.test(relPath)) return false;
359
+ return true;
360
+ }
361
+
362
+ function shouldMirrorSourceFile(filePath) {
363
+ return SOURCE_MIRROR_EXTENSIONS.has(extname(filePath).toLowerCase());
364
+ }
365
+
366
+ function shouldRewriteImports(filePath) {
367
+ return IMPORT_REWRITE_EXTENSIONS.has(extname(filePath).toLowerCase());
368
+ }
369
+
370
+ function rewriteImportSpecifier(specifier, sourceFilePath, outputFilePath) {
371
+ const match = specifier.match(/^([^?#]+)([?#].*)?$/);
372
+ if (!match) return specifier;
373
+ const rawPath = match[1];
374
+ const suffix = match[2] || '';
375
+
376
+ let resolvedSourcePath = null;
377
+ if (rawPath.startsWith('/')) {
378
+ resolvedSourcePath = join(docsDir, rawPath.slice(1));
379
+ } else if (rawPath.startsWith('./') || rawPath.startsWith('../')) {
380
+ resolvedSourcePath = resolve(dirname(sourceFilePath), rawPath);
381
+ }
382
+
383
+ if (!resolvedSourcePath || !isInsideDocsRoot(resolvedSourcePath)) {
384
+ return specifier;
385
+ }
386
+
387
+ const relToDocs = relative(docsDir, resolvedSourcePath);
388
+ const mirrorTargetPath = join(sourceMirrorDir, relToDocs);
389
+ const relFromOutput = relative(dirname(outputFilePath), mirrorTargetPath);
390
+ const normalizedRel = toPosixPath(relFromOutput || '.');
391
+ const withDotPrefix = normalizedRel.startsWith('.') ? normalizedRel : `./${normalizedRel}`;
392
+ return `${withDotPrefix}${suffix}`;
393
+ }
394
+
395
+ function rewriteImportsInContent(content, sourceFilePath, outputFilePath) {
396
+ const importFromPattern = /^(\s*import\s+)(.+?)(\s+from\s*["'])([^"']+)(["']\s*;?\s*)$/;
397
+ const exportFromPattern = /^(\s*export\b[^\n]*?\bfrom\s*["'])([^"']+)(["'])/;
398
+ const sideEffectImportPattern = /^(\s*import\s*["'])([^"']+)(["'])/;
399
+ const fencePattern = /^\s*(```+|~~~+)/;
400
+ const mdxOutput = (() => {
401
+ const ext = extname(outputFilePath).toLowerCase();
402
+ return ext === '.md' || ext === '.mdx';
403
+ })();
404
+
405
+ const lines = content.split(/\r?\n/);
406
+ const out = [];
407
+ let inFence = false;
408
+ let fenceChar = '';
409
+ let injectedMdxHelperImport = false;
410
+
411
+ function importPathFromSpecifier(specifier) {
412
+ const match = specifier.match(/^([^?#]+)/);
413
+ return match ? match[1] : specifier;
414
+ }
415
+
416
+ function isLocalSpecifier(specifier) {
417
+ return specifier.startsWith('/') || specifier.startsWith('./') || specifier.startsWith('../');
418
+ }
419
+
420
+ function isMdxSpecifier(specifier) {
421
+ const base = importPathFromSpecifier(specifier).toLowerCase();
422
+ return base.endsWith('.mdx') || base.endsWith('.md');
423
+ }
424
+
425
+ function parseDefaultImport(clause) {
426
+ const trimmed = clause.trim();
427
+ if (!trimmed || trimmed.startsWith('{') || trimmed.startsWith('*')) return {};
428
+
429
+ const commaIdx = trimmed.indexOf(',');
430
+ if (commaIdx === -1) {
431
+ if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(trimmed)) return { defaultName: trimmed };
432
+ return {};
433
+ }
434
+
435
+ const defaultName = trimmed.slice(0, commaIdx).trim();
436
+ const remainder = trimmed.slice(commaIdx + 1).trim();
437
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(defaultName)) return {};
438
+ if (!remainder.startsWith('{') && !remainder.startsWith('*')) return {};
439
+ return { defaultName, namedPart: remainder };
440
+ }
441
+
442
+ for (const line of lines) {
443
+ const fenceMatch = line.match(fencePattern);
444
+ if (fenceMatch) {
445
+ const currentFenceChar = fenceMatch[1][0];
446
+ if (!inFence) {
447
+ inFence = true;
448
+ fenceChar = currentFenceChar;
449
+ } else if (fenceChar === currentFenceChar) {
450
+ inFence = false;
451
+ fenceChar = '';
452
+ }
453
+ out.push(line);
454
+ continue;
455
+ }
456
+
457
+ if (inFence) {
458
+ out.push(line);
459
+ continue;
460
+ }
461
+
462
+ const importMatch = line.match(importFromPattern);
463
+ if (importMatch) {
464
+ const importPrefix = importMatch[1];
465
+ const importClause = importMatch[2];
466
+ const fromPrefix = importMatch[3];
467
+ const specifier = importMatch[4];
468
+ const importSuffix = importMatch[5];
469
+ const rewritten = rewriteImportSpecifier(specifier, sourceFilePath, outputFilePath);
470
+ const { defaultName, namedPart } = parseDefaultImport(importClause);
471
+ const shouldWrapDefaultImport =
472
+ mdxOutput && Boolean(defaultName) && isLocalSpecifier(specifier) && isMdxSpecifier(specifier);
473
+
474
+ if (shouldWrapDefaultImport && defaultName) {
475
+ if (!injectedMdxHelperImport) {
476
+ out.push('import { getMDXComponents as __veluGetMDXComponents } from "@/mdx-components";');
477
+ injectedMdxHelperImport = true;
478
+ }
479
+
480
+ const rawName = `__veluRaw_${defaultName}`;
481
+ const wrappedClause = namedPart ? `${rawName}, ${namedPart}` : rawName;
482
+ out.push(`${importPrefix}${wrappedClause}${fromPrefix}${rewritten}${importSuffix}`);
483
+ out.push(`export const ${defaultName} = (props) => <${rawName} {...props} components={__veluGetMDXComponents()} />;`);
484
+ continue;
485
+ }
486
+
487
+ out.push(`${importPrefix}${importClause}${fromPrefix}${rewritten}${importSuffix}`);
488
+ continue;
489
+ }
490
+
491
+ let nextLine = line.replace(exportFromPattern, (_, prefix, specifier, suffix) => {
492
+ const rewritten = rewriteImportSpecifier(specifier, sourceFilePath, outputFilePath);
493
+ return `${prefix}${rewritten}${suffix}`;
494
+ });
495
+
496
+ nextLine = nextLine.replace(sideEffectImportPattern, (_, prefix, specifier, suffix) => {
497
+ const rewritten = rewriteImportSpecifier(specifier, sourceFilePath, outputFilePath);
498
+ return `${prefix}${rewritten}${suffix}`;
499
+ });
500
+
501
+ out.push(nextLine);
502
+ }
503
+
504
+ return out.join('\n');
505
+ }
506
+
507
+ function copyMirroredSourceFile(srcPath) {
508
+ if (!shouldMirrorSourceFile(srcPath)) return;
509
+ if (!isInsideDocsRoot(srcPath)) return;
510
+
511
+ const relPath = relative(docsDir, srcPath);
512
+ const destPath = join(sourceMirrorDir, relPath);
513
+ mkdirSync(dirname(destPath), { recursive: true });
514
+
515
+ if (shouldRewriteImports(srcPath)) {
516
+ const raw = readFileSync(srcPath, 'utf-8');
517
+ const rewritten = rewriteImportsInContent(raw, srcPath, destPath);
518
+ writeFileSync(destPath, rewritten, 'utf-8');
519
+ return;
520
+ }
521
+
522
+ copyFileSync(srcPath, destPath);
523
+ }
524
+
525
+ function rebuildSourceMirror() {
526
+ rmSync(sourceMirrorDir, { recursive: true, force: true });
527
+ mkdirSync(sourceMirrorDir, { recursive: true });
528
+
529
+ function walk(dir) {
530
+ const entries = readdirSync(dir, { withFileTypes: true });
531
+ for (const entry of entries) {
532
+ if (entry.name.startsWith('.')) continue;
533
+ if (entry.name === 'node_modules') continue;
534
+ const srcPath = join(dir, entry.name);
535
+ if (entry.isDirectory()) {
536
+ walk(srcPath);
537
+ continue;
538
+ }
539
+ if (!shouldMirrorSourceFile(srcPath)) continue;
540
+ copyMirroredSourceFile(srcPath);
541
+ }
542
+ }
543
+
544
+ walk(docsDir);
545
+ }
546
+
547
+ function syncSourceMirrorFile(filename) {
548
+ const srcPath = join(docsDir, filename);
549
+ const destPath = join(sourceMirrorDir, filename);
550
+ if (!shouldMirrorSourceFile(srcPath)) return;
551
+
552
+ if (existsSync(srcPath)) {
553
+ copyMirroredSourceFile(srcPath);
554
+ return;
555
+ }
556
+
557
+ rmSync(destPath, { force: true });
558
+ }
559
+
560
+ function loadConfig() {
561
+ const raw = readFileSync(resolveConfigPath(), 'utf-8');
562
+ return normalizeConfigNavigation(JSON.parse(raw));
563
+ }
564
+
565
+ function isExternalDestination(value) {
566
+ return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value);
567
+ }
568
+
569
+ function normalizePath(value) {
570
+ const trimmed = String(value || '').trim();
571
+ if (!trimmed) return '/';
572
+ const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
573
+ const collapsed = withLeadingSlash.replace(/\/{2,}/g, '/');
574
+ if (collapsed !== '/' && collapsed.endsWith('/')) return collapsed.slice(0, -1);
575
+ return collapsed;
576
+ }
577
+
578
+ function collectRedirectRules(config) {
579
+ const redirects = Array.isArray(config?.redirects) ? config.redirects : [];
580
+ const output = [];
581
+
582
+ for (const redirect of redirects) {
583
+ if (!redirect || typeof redirect.source !== 'string' || typeof redirect.destination !== 'string') continue;
584
+ const source = redirect.source.trim();
585
+ const destination = redirect.destination.trim();
586
+ if (!source || !destination) continue;
587
+ if (/[?#]/.test(source) || /[?#]/.test(destination)) continue;
588
+
589
+ const normalizedSource = normalizePath(source);
590
+ const normalizedDestination = isExternalDestination(destination)
591
+ ? destination
592
+ : normalizePath(destination);
593
+ if (!isExternalDestination(normalizedDestination) && normalizedSource === normalizedDestination) continue;
594
+
595
+ output.push({
596
+ source: normalizedSource,
597
+ destination: normalizedDestination,
598
+ permanent: redirect.permanent !== false,
599
+ });
600
+ }
601
+
602
+ return output;
603
+ }
604
+
605
+ function writeRedirectArtifacts(config) {
606
+ const redirects = collectRedirectRules(config);
607
+ const generatedDir = resolve('generated');
608
+ mkdirSync(generatedDir, { recursive: true });
609
+ writeFileSync(
610
+ join(generatedDir, 'redirects.ts'),
611
+ `const redirects: Array<{ source: string; destination: string; permanent: boolean }> = ${JSON.stringify(redirects, null, 2)};\n\nexport default redirects;\n`,
612
+ 'utf-8'
613
+ );
614
+
615
+ const redirectsFilePath = join(publicDir, '_redirects');
616
+ if (redirects.length === 0) {
617
+ rmSync(redirectsFilePath, { force: true });
618
+ return;
619
+ }
620
+
621
+ const netlifyBody = redirects
622
+ .map((redirect) => `${redirect.source} ${redirect.destination} ${redirect.permanent ? 301 : 307}`)
623
+ .join('\n');
624
+ writeFileSync(redirectsFilePath, `${netlifyBody}\n`, 'utf-8');
625
+ }
626
+
627
+ function pageBasename(page) {
628
+ return page.split('/').pop();
629
+ }
630
+
631
+ function pageLabelFromSlug(slug) {
632
+ const last = slug.split('/').pop() || slug;
633
+ return last.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
634
+ }
635
+
636
+ function isSeparator(item) {
637
+ return typeof item === 'object' && item !== null && 'separator' in item;
638
+ }
639
+
640
+ function isLink(item) {
641
+ return typeof item === 'object' && item !== null && 'href' in item && 'label' in item;
642
+ }
643
+
644
+ function isGroup(item) {
645
+ return typeof item === 'object' && item !== null && 'group' in item;
646
+ }
647
+
648
+ const HTTP_METHODS = new Set([
649
+ 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD', 'TRACE', 'CONNECT', 'WEBHOOK',
650
+ ]);
651
+ const OPENAPI_PATH_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']);
652
+ const ASYNCAPI_OPERATION_ACTIONS = new Set(['publish', 'subscribe', 'send', 'receive']);
653
+
654
+ function resolveDefaultOpenApiSpec(openapi) {
655
+ const source = extractOpenApiSource(openapi);
656
+ if (typeof source === 'string') {
657
+ const trimmed = source.trim();
658
+ return trimmed.length > 0 ? trimmed : undefined;
137
659
  }
660
+ if (Array.isArray(source)) {
661
+ const first = source.find((entry) => typeof entry === 'string' && entry.trim().length > 0);
662
+ return typeof first === 'string' ? first.trim() : undefined;
663
+ }
664
+ return undefined;
665
+ }
138
666
 
139
- return { pageMap, metaFiles, firstPage };
667
+ function extractOpenApiSource(openapi) {
668
+ if (typeof openapi === 'string' || Array.isArray(openapi)) return openapi;
669
+ if (openapi && typeof openapi === 'object') {
670
+ const source = openapi.source;
671
+ if (typeof source === 'string' || Array.isArray(source)) return source;
672
+ }
673
+ return undefined;
674
+ }
675
+
676
+ function resolveOpenApiDirectory(openapi) {
677
+ if (!openapi || typeof openapi !== 'object' || Array.isArray(openapi)) return undefined;
678
+ const raw = openapi.directory;
679
+ if (typeof raw !== 'string') return undefined;
680
+ const trimmed = raw.trim().replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
681
+ return trimmed.length > 0 ? trimmed : undefined;
682
+ }
683
+
684
+ function resolveDefaultAsyncApiSpec(asyncapi) {
685
+ const source = extractOpenApiSource(asyncapi);
686
+ if (typeof source === 'string') {
687
+ const trimmed = source.trim();
688
+ return trimmed.length > 0 ? trimmed : undefined;
689
+ }
690
+ if (Array.isArray(source)) {
691
+ const first = source.find((entry) => typeof entry === 'string' && entry.trim().length > 0);
692
+ return typeof first === 'string' ? first.trim() : undefined;
693
+ }
694
+ return undefined;
695
+ }
696
+
697
+ function resolveAsyncApiSpecList(asyncapi) {
698
+ const source = extractOpenApiSource(asyncapi);
699
+ if (typeof source === 'string') {
700
+ const trimmed = source.trim();
701
+ return trimmed ? [trimmed] : [];
702
+ }
703
+ if (Array.isArray(source)) {
704
+ return source
705
+ .filter((entry) => typeof entry === 'string')
706
+ .map((entry) => entry.trim())
707
+ .filter((entry) => entry.length > 0);
708
+ }
709
+ return [];
710
+ }
711
+
712
+ function resolveAsyncApiDirectory(asyncapi) {
713
+ if (!asyncapi || typeof asyncapi !== 'object' || Array.isArray(asyncapi)) return undefined;
714
+ const raw = asyncapi.directory;
715
+ if (typeof raw !== 'string') return undefined;
716
+ const trimmed = raw.trim().replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
717
+ return trimmed.length > 0 ? trimmed : undefined;
140
718
  }
141
719
 
142
- function processPage(srcPath, destPath, slug) {
143
- let content = readFileSync(srcPath, 'utf-8');
144
- if (!content.startsWith('---')) {
145
- const titleMatch = content.match(/^#\s+(.+)$/m);
146
- const title = titleMatch ? titleMatch[1] : pageLabelFromSlug(slug);
147
- if (titleMatch) {
148
- content = content.replace(/^#\s+.+$/m, '').trimStart();
720
+ function parseOpenApiOperationRef(value, inheritedSpec) {
721
+ const trimmed = String(value ?? '').trim();
722
+ if (!trimmed) return null;
723
+
724
+ const withSpec = trimmed.match(/^(\S+)\s+([A-Za-z]+)\s+(.+)$/);
725
+ if (withSpec) {
726
+ const method = withSpec[2].toUpperCase();
727
+ const endpoint = withSpec[3].trim();
728
+ if (!HTTP_METHODS.has(method)) return null;
729
+ if (method === 'WEBHOOK') {
730
+ if (!endpoint) return null;
731
+ return { spec: withSpec[1].trim(), method, endpoint, kind: 'webhook' };
149
732
  }
150
- content = `---\ntitle: "${title}"\n---\n\n${content}`;
733
+ if (!endpoint.startsWith('/')) return null;
734
+ return { spec: withSpec[1].trim(), method, endpoint, kind: 'path' };
151
735
  }
152
736
 
153
- mkdirSync(dirname(destPath), { recursive: true });
154
- writeFileSync(destPath, content, 'utf-8');
737
+ const noSpec = trimmed.match(/^([A-Za-z]+)\s+(.+)$/);
738
+ if (!noSpec) return null;
739
+ const method = noSpec[1].toUpperCase();
740
+ const endpoint = noSpec[2].trim();
741
+ if (!HTTP_METHODS.has(method)) return null;
742
+ if (method === 'WEBHOOK') {
743
+ if (!endpoint) return null;
744
+ return { spec: inheritedSpec, method, endpoint, kind: 'webhook' };
745
+ }
746
+ if (!endpoint.startsWith('/')) return null;
747
+ return { spec: inheritedSpec, method, endpoint, kind: 'path' };
155
748
  }
156
749
 
157
- function writeMetaFiles(metaFiles) {
158
- for (const meta of metaFiles) {
159
- const metaPath = join(contentDir, meta.dir, 'meta.json');
160
- mkdirSync(dirname(metaPath), { recursive: true });
161
- writeFileSync(metaPath, JSON.stringify(meta.data, null, 2) + '\n', 'utf-8');
750
+ function parseAsyncApiChannelRef(value, inheritedSpec) {
751
+ const trimmed = String(value ?? '').trim();
752
+ if (!trimmed) return null;
753
+
754
+ const withSpec = trimmed.match(/^(\S+)\s+(.+)$/);
755
+ if (withSpec) {
756
+ const maybeMethod = withSpec[1].toUpperCase();
757
+ if (HTTP_METHODS.has(maybeMethod)) return null;
758
+ const spec = withSpec[1].trim();
759
+ const channel = withSpec[2].trim();
760
+ if (!channel) return null;
761
+ return { spec, channel };
162
762
  }
763
+
764
+ if (!inheritedSpec) return null;
765
+ return { spec: inheritedSpec, channel: trimmed };
766
+ }
767
+
768
+ function slugFromOpenApiOperation(method, endpoint) {
769
+ const cleaned = endpoint
770
+ .toLowerCase()
771
+ .replace(/^\/+/, '')
772
+ .replace(/[{}]/g, '')
773
+ .replace(/[^a-z0-9/._-]+/g, '-')
774
+ .replace(/\/+/g, '-')
775
+ .replace(/[-_.]{2,}/g, '-')
776
+ .replace(/^[-_.]+|[-_.]+$/g, '');
777
+ const body = cleaned || 'endpoint';
778
+ return `${method.toLowerCase()}-${body}`;
163
779
  }
164
780
 
165
- function writeIndexPage(firstPage) {
166
- writeFileSync(
167
- join(contentDir, 'index.mdx'),
168
- `---\ntitle: "Overview"\ndescription: Documentation powered by Velu\n---\n\nimport { Card, Cards } from "fumadocs-ui/components/card"\nimport { Callout } from "fumadocs-ui/components/callout"\n\n<Callout type="info">\n Welcome to your documentation site.\n</Callout>\n\n## Start here\n\n<Cards>\n <Card\n title="Read the docs"\n href="/${firstPage}/"\n description="Begin with the first page in your configured navigation."\n />\n</Cards>\n`,
169
- 'utf-8'
170
- );
781
+ function resolveOpenApiSpecList(openapi) {
782
+ const source = extractOpenApiSource(openapi);
783
+ if (typeof source === 'string') {
784
+ const trimmed = source.trim();
785
+ return trimmed ? [trimmed] : [];
786
+ }
787
+ if (Array.isArray(source)) {
788
+ return source
789
+ .filter((entry) => typeof entry === 'string')
790
+ .map((entry) => entry.trim())
791
+ .filter((entry) => entry.length > 0);
792
+ }
793
+ return [];
171
794
  }
172
795
 
173
- function writeLangContent(langCode, artifacts, isDefault, useLangFolders = false) {
174
- const storagePrefix = useLangFolders ? langCode : (isDefault ? '' : langCode);
175
- const urlPrefix = isDefault ? '' : langCode;
796
+ function parseOpenApiDocument(rawSource) {
797
+ const source = String(rawSource ?? '').trim();
798
+ if (!source) return null;
799
+ try {
800
+ const parsed = JSON.parse(source);
801
+ if (parsed && typeof parsed === 'object') return parsed;
802
+ } catch {}
803
+ try {
804
+ const parsed = parseYaml(source);
805
+ if (parsed && typeof parsed === 'object') return parsed;
806
+ } catch {}
807
+ return null;
808
+ }
176
809
 
177
- // Write meta files (prefixed for non-default)
178
- const metaFiles = storagePrefix
179
- ? artifacts.metaFiles.map((meta) => ({
180
- dir: meta.dir ? `${storagePrefix}/${meta.dir}` : storagePrefix,
181
- data: { ...meta.data },
182
- }))
183
- : artifacts.metaFiles;
184
- writeMetaFiles(metaFiles);
810
+ function parseAsyncApiDocument(rawSource) {
811
+ return parseOpenApiDocument(rawSource);
812
+ }
185
813
 
186
- // Copy pages using explicit source paths from velu.json
187
- for (const { src, dest } of artifacts.pageMap) {
188
- const srcPath = join(docsDir, `${src}.md`);
189
- if (!existsSync(srcPath)) {
190
- console.warn(` \x1b[33m⚠\x1b[0m Missing page source: ${src}.md (language: ${langCode})`);
191
- continue;
192
- }
193
- const destPath = join(contentDir, storagePrefix ? `${storagePrefix}/${dest}.mdx` : `${dest}.mdx`);
194
- processPage(srcPath, destPath, src);
814
+ function resolveRef(root, ref) {
815
+ const refText = String(ref ?? '');
816
+ if (!refText.startsWith('#/')) return undefined;
817
+ const parts = refText.slice(2).split('/').map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~'));
818
+ let current = root;
819
+ for (const part of parts) {
820
+ if (!current || typeof current !== 'object') return undefined;
821
+ current = current[part];
195
822
  }
823
+ return current;
824
+ }
196
825
 
197
- // Index page
198
- const href = urlPrefix ? `/${urlPrefix}/${artifacts.firstPage}/` : `/${artifacts.firstPage}/`;
199
- const indexPath = storagePrefix ? join(contentDir, storagePrefix, 'index.mdx') : join(contentDir, 'index.mdx');
200
- writeFileSync(
201
- indexPath,
202
- `---\ntitle: "Overview"\ndescription: Documentation powered by Velu\n---\n\nimport { Card, Cards } from "fumadocs-ui/components/card"\nimport { Callout } from "fumadocs-ui/components/callout"\n\n<Callout type="info">\n Welcome to your documentation site.\n</Callout>\n\n## Start here\n\n<Cards>\n <Card\n title="Read the docs"\n href="${href}"\n description="Begin with the first page in your configured navigation."\n />\n</Cards>\n`,
203
- 'utf-8'
204
- );
826
+ function loadAsyncApiChannels(specSource) {
827
+ if (/^https?:\/\//i.test(specSource) || specSource.startsWith('file://')) return [];
828
+
829
+ const resolvedPath = specSource.startsWith('/')
830
+ ? join(docsDir, specSource.replace(/^\/+/, ''))
831
+ : resolve(docsDir, specSource);
832
+ if (!existsSync(resolvedPath)) return [];
833
+
834
+ const parsed = parseAsyncApiDocument(readFileSync(resolvedPath, 'utf-8'));
835
+ if (!parsed || typeof parsed !== 'object') return [];
836
+ const channels = parsed.channels;
837
+ if (!channels || typeof channels !== 'object') return [];
838
+
839
+ return Object.entries(channels)
840
+ .map(([channelName, rawChannel]) => {
841
+ const channel = rawChannel && typeof rawChannel === 'object' ? rawChannel : {};
842
+ const title = typeof channel.title === 'string' ? channel.title : undefined;
843
+ const description = typeof channel.description === 'string' ? channel.description : undefined;
844
+ return {
845
+ spec: specSource,
846
+ channel: channelName,
847
+ title: title ?? channelName,
848
+ description,
849
+ };
850
+ })
851
+ .filter((entry) => typeof entry.channel === 'string' && entry.channel.trim().length > 0);
205
852
  }
206
853
 
207
- function rebuildFromConfig() {
208
- const config = loadConfig();
209
- const navLanguages = config.navigation?.languages;
210
- const simpleLanguages = config.languages || [];
854
+ function getAsyncApiChannelInfo(specSource, channelName) {
855
+ if (/^https?:\/\//i.test(specSource) || specSource.startsWith('file://')) return null;
211
856
 
212
- rmSync(contentDir, { recursive: true, force: true });
213
- mkdirSync(contentDir, { recursive: true });
857
+ const resolvedPath = specSource.startsWith('/')
858
+ ? join(docsDir, specSource.replace(/^\/+/, ''))
859
+ : resolve(docsDir, specSource);
860
+ if (!existsSync(resolvedPath)) return null;
214
861
 
215
- // ── Mode 1: Per-language navigation (Mintlify-style) ──────────────
216
- if (navLanguages && navLanguages.length > 0) {
217
- const rootPages = [];
862
+ const parsed = parseAsyncApiDocument(readFileSync(resolvedPath, 'utf-8'));
863
+ if (!parsed || typeof parsed !== 'object') return null;
218
864
 
219
- for (let i = 0; i < navLanguages.length; i++) {
220
- const langEntry = navLanguages[i];
221
- const langCode = langEntry.language;
222
- const isDefault = i === 0;
865
+ const channels = parsed.channels;
866
+ if (!channels || typeof channels !== 'object') return null;
223
867
 
224
- // Build artifacts using this language's own tabs
225
- const langConfig = { ...config, navigation: { ...config.navigation, tabs: langEntry.tabs } };
226
- const artifacts = buildArtifacts(langConfig);
868
+ const channelObject = channels[channelName];
869
+ if (!channelObject || typeof channelObject !== 'object') {
870
+ const byAddress = Object.entries(channels).find(([, rawChannel]) => {
871
+ if (!rawChannel || typeof rawChannel !== 'object') return false;
872
+ const address = rawChannel.address;
873
+ return typeof address === 'string' && address === channelName;
874
+ });
875
+ if (!byAddress) return null;
876
+ const [, matched] = byAddress;
877
+ return {
878
+ channel: byAddress[0],
879
+ title: typeof matched.title === 'string' ? matched.title : byAddress[0],
880
+ description: typeof matched.description === 'string' ? matched.description : undefined,
881
+ };
882
+ }
883
+
884
+ return {
885
+ channel: channelName,
886
+ title: typeof channelObject.title === 'string' ? channelObject.title : channelName,
887
+ description: typeof channelObject.description === 'string' ? channelObject.description : undefined,
888
+ };
889
+ }
890
+
891
+ function readMintMetadata(operation) {
892
+ const xMint = operation['x-mint'];
893
+ if (!xMint || typeof xMint !== 'object') return {};
894
+ const metadata = xMint.metadata;
895
+ const content = xMint.content;
896
+ const meta = metadata && typeof metadata === 'object' ? metadata : {};
897
+ return {
898
+ title: typeof meta.title === 'string' ? meta.title : undefined,
899
+ description: typeof meta.description === 'string' ? meta.description : undefined,
900
+ deprecated: typeof meta.deprecated === 'boolean' ? meta.deprecated : undefined,
901
+ version: typeof meta.version === 'string' ? meta.version : undefined,
902
+ content: typeof content === 'string' ? content : undefined,
903
+ };
904
+ }
905
+
906
+ function readVisibilityMetadata(operation) {
907
+ return {
908
+ hidden: operation['x-hidden'] === true,
909
+ excluded: operation['x-excluded'] === true,
910
+ };
911
+ }
912
+
913
+ function normalizeWebhookKey(name) {
914
+ const value = String(name ?? '').trim();
915
+ if (!value) return value;
916
+ return value.startsWith('/') ? value : `/${value}`;
917
+ }
918
+
919
+ function readOpenApiOperationInfo(operation) {
920
+ if (!operation || typeof operation !== 'object') return {};
921
+ const mintMeta = readMintMetadata(operation);
922
+ const visibility = readVisibilityMetadata(operation);
923
+ return {
924
+ ...visibility,
925
+ title: mintMeta.title ?? (typeof operation.summary === 'string' ? operation.summary : undefined),
926
+ description: mintMeta.description ?? (typeof operation.description === 'string' ? operation.description : undefined),
927
+ deprecated: mintMeta.deprecated ?? (operation.deprecated === true),
928
+ version: mintMeta.version,
929
+ content: mintMeta.content,
930
+ };
931
+ }
932
+
933
+ function resolveSpecPath(specSource) {
934
+ if (/^https?:\/\//i.test(specSource) || specSource.startsWith('file://')) return undefined;
935
+ const resolvedPath = specSource.startsWith('/')
936
+ ? join(docsDir, specSource.replace(/^\/+/, ''))
937
+ : resolve(docsDir, specSource);
938
+ return existsSync(resolvedPath) ? resolvedPath : undefined;
939
+ }
940
+
941
+ function loadOpenApiDocument(specSource) {
942
+ const resolvedPath = resolveSpecPath(specSource);
943
+ if (!resolvedPath) return null;
944
+ return parseOpenApiDocument(readFileSync(resolvedPath, 'utf-8'));
945
+ }
227
946
 
228
- writeLangContent(langCode, artifacts, isDefault, true);
229
- rootPages.push(`!${langCode}`);
947
+ function getOpenApiOperationInfo(specSource, kind, method, endpoint) {
948
+ const parsed = loadOpenApiDocument(specSource);
949
+ if (!parsed) return null;
950
+
951
+ if (kind === 'webhook') {
952
+ const webhooks = parsed.webhooks;
953
+ if (!webhooks || typeof webhooks !== 'object') return null;
954
+ const target = normalizeWebhookKey(endpoint);
955
+ const entries = Object.entries(webhooks);
956
+ const resolvedEntry = entries.find(([name]) => name === endpoint || normalizeWebhookKey(name) === target);
957
+ if (!resolvedEntry) return null;
958
+ const pathItem = resolvedEntry[1];
959
+ if (!pathItem || typeof pathItem !== 'object') return null;
960
+ const methodKey = method === 'WEBHOOK' ? pickOperationMethod(pathItem)?.toLowerCase() : method.toLowerCase();
961
+ if (!methodKey) return null;
962
+ return readOpenApiOperationInfo(pathItem[methodKey]);
963
+ }
964
+
965
+ const paths = parsed.paths;
966
+ if (!paths || typeof paths !== 'object') return null;
967
+ const pathItem = paths[endpoint];
968
+ if (!pathItem || typeof pathItem !== 'object') return null;
969
+ return readOpenApiOperationInfo(pathItem[method.toLowerCase()]);
970
+ }
971
+
972
+ function pickOperationMethod(pathItem) {
973
+ for (const method of OPENAPI_PATH_METHODS) {
974
+ const operation = pathItem[method];
975
+ if (operation && typeof operation === 'object') return method.toUpperCase();
976
+ }
977
+ return undefined;
978
+ }
979
+
980
+ function loadOpenApiOperations(specSource) {
981
+ const parsed = loadOpenApiDocument(specSource);
982
+ if (!parsed) return [];
983
+ const paths = parsed.paths;
984
+ const webhooks = parsed.webhooks;
985
+
986
+ const output = [];
987
+ if (paths && typeof paths === 'object') {
988
+ for (const [endpoint, methods] of Object.entries(paths)) {
989
+ if (!endpoint.startsWith('/') || !methods || typeof methods !== 'object') continue;
990
+ for (const method of Object.keys(methods)) {
991
+ const normalized = method.toLowerCase();
992
+ if (!OPENAPI_PATH_METHODS.has(normalized)) continue;
993
+ const operation = methods[method];
994
+ if (!operation || typeof operation !== 'object') continue;
995
+ const operationInfo = readOpenApiOperationInfo(operation);
996
+ if (operationInfo.excluded) continue;
997
+ output.push({
998
+ kind: 'path',
999
+ spec: specSource,
1000
+ method: normalized.toUpperCase(),
1001
+ endpoint,
1002
+ title: operationInfo.title,
1003
+ description: operationInfo.description,
1004
+ deprecated: operationInfo.deprecated,
1005
+ version: operationInfo.version,
1006
+ content: operationInfo.content,
1007
+ hidden: operationInfo.hidden,
1008
+ });
1009
+ }
230
1010
  }
1011
+ }
231
1012
 
232
- // Write root meta with default tabs + hidden language folders
233
- writeFileSync(
234
- join(contentDir, 'meta.json'),
235
- JSON.stringify({ pages: rootPages }, null, 2) + '\n',
236
- 'utf-8'
237
- );
1013
+ if (webhooks && typeof webhooks === 'object') {
1014
+ for (const [webhookName, pathItem] of Object.entries(webhooks)) {
1015
+ if (!pathItem || typeof pathItem !== 'object') continue;
1016
+ const resolvedMethod = pickOperationMethod(pathItem);
1017
+ if (!resolvedMethod) continue;
1018
+ const operation = pathItem[resolvedMethod.toLowerCase()];
1019
+ if (!operation || typeof operation !== 'object') continue;
1020
+ const operationInfo = readOpenApiOperationInfo(operation);
1021
+ if (operationInfo.excluded) continue;
1022
+ output.push({
1023
+ kind: 'webhook',
1024
+ spec: specSource,
1025
+ method: 'WEBHOOK',
1026
+ endpoint: webhookName,
1027
+ title: operationInfo.title,
1028
+ description: operationInfo.description,
1029
+ deprecated: operationInfo.deprecated,
1030
+ version: operationInfo.version,
1031
+ content: operationInfo.content,
1032
+ hidden: operationInfo.hidden,
1033
+ });
1034
+ }
1035
+ }
1036
+ return output;
1037
+ }
1038
+
1039
+ function normalizeOpenApiSpecForFrontmatter(spec) {
1040
+ if (!spec) return undefined;
1041
+ const trimmed = String(spec).trim();
1042
+ if (!trimmed) return undefined;
1043
+ if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith('file://')) return trimmed;
1044
+ if (trimmed.startsWith('/')) return trimmed;
1045
+ return `/${trimmed.replace(/^\.?\/*/, '')}`;
1046
+ }
1047
+
1048
+ function metaEntry(item) {
1049
+ if (typeof item === 'string') return item;
1050
+ if (isSeparator(item)) return `---${item.separator}---`;
1051
+ if (isLink(item)) {
1052
+ return item.icon
1053
+ ? `[${item.icon}][${item.label}](${item.href})`
1054
+ : `[${item.label}](${item.href})`;
1055
+ }
1056
+ return String(item);
1057
+ }
1058
+
1059
+ function buildArtifacts(config) {
1060
+ const pageMap = [];
1061
+ const metaFiles = [];
1062
+ const rootTabs = (config.navigation?.tabs || []).filter((tab) => !tab.href);
1063
+ const rootPages = rootTabs.map((tab) => tab.slug);
1064
+ const defaultOpenApiSpec = resolveDefaultOpenApiSpec(config.navigation?.openapi ?? config.openapi);
1065
+ const defaultAsyncApiSpec = resolveDefaultAsyncApiSpec(config.navigation?.asyncapi ?? config.asyncapi);
1066
+ let firstPage = 'quickstart';
1067
+ let hasFirstPage = false;
1068
+ let firstHiddenPageCandidate;
1069
+ const usedDestinations = new Set();
238
1070
 
239
- // Return the default language's page map for file watching
240
- const defaultConfig = { ...config, navigation: { ...config.navigation, tabs: navLanguages[0].tabs } };
241
- return buildArtifacts(defaultConfig).pageMap;
1071
+ function trackFirstPage(dest, hidden = false) {
1072
+ if (!hidden && !hasFirstPage) {
1073
+ firstPage = dest;
1074
+ hasFirstPage = true;
1075
+ return;
1076
+ }
1077
+ if (hidden && !hasFirstPage && !firstHiddenPageCandidate) {
1078
+ firstHiddenPageCandidate = dest;
1079
+ }
242
1080
  }
1081
+
1082
+ function uniqueDestination(dest) {
1083
+ if (!usedDestinations.has(dest)) {
1084
+ usedDestinations.add(dest);
1085
+ return dest;
1086
+ }
1087
+ let count = 2;
1088
+ while (usedDestinations.has(`${dest}-${count}`)) count += 1;
1089
+ const candidate = `${dest}-${count}`;
1090
+ usedDestinations.add(candidate);
1091
+ return candidate;
1092
+ }
1093
+
1094
+ function metaEntryForDestination(baseDir, destination) {
1095
+ const fromParts = baseDir.split('/').filter(Boolean);
1096
+ const toParts = destination.split('/').filter(Boolean);
243
1097
 
244
- // ── Mode 2: Simple multi-lang (same nav, content in docs/<lang>/) ─
245
- const artifacts = buildArtifacts(config);
1098
+ let index = 0;
1099
+ while (index < fromParts.length && index < toParts.length && fromParts[index] === toParts[index]) {
1100
+ index += 1;
1101
+ }
246
1102
 
247
- const useLangFolders = simpleLanguages.length > 1;
248
- writeLangContent(simpleLanguages[0] || 'en', artifacts, true, useLangFolders);
1103
+ const up = Array(fromParts.length - index).fill('..');
1104
+ const down = toParts.slice(index);
1105
+ const rel = [...up, ...down].join('/');
1106
+ return rel || pageBasename(destination);
1107
+ }
249
1108
 
250
- if (simpleLanguages.length > 1) {
251
- const rootMetaPath = join(contentDir, 'meta.json');
252
- const rootPages = [`!${simpleLanguages[0] || 'en'}`];
1109
+ function resolveGenerationDestination(openapi, fallback) {
1110
+ const override = resolveOpenApiDirectory(openapi) ?? resolveAsyncApiDirectory(openapi);
1111
+ if (!override) return fallback;
1112
+ if (!fallback) return override;
1113
+ if (override === fallback || override.startsWith(`${fallback}/`)) return override;
1114
+ return `${fallback}/${override}`;
1115
+ }
1116
+
1117
+ function toFilePageMapping(item, destDir) {
1118
+ const basename = pageBasename(item);
1119
+ const dest = uniqueDestination(`${destDir}/${basename}`);
1120
+ return { src: item, dest, kind: 'file' };
1121
+ }
253
1122
 
254
- for (const lang of simpleLanguages.slice(1)) {
255
- writeLangContent(lang, artifacts, false, true);
256
- rootPages.push(`!${lang}`);
1123
+ function toPageMapping(item, destDir, inheritedSpec, mode = 'file-fallback') {
1124
+ const parsedOpenApi = parseOpenApiOperationRef(item, inheritedSpec);
1125
+ if (!parsedOpenApi) {
1126
+ if (mode === 'operation-only') return undefined;
1127
+ return toFilePageMapping(item, destDir);
257
1128
  }
258
1129
 
259
- writeFileSync(rootMetaPath, JSON.stringify({ pages: rootPages }, null, 2) + '\n', 'utf-8');
1130
+ const operationInfo = parsedOpenApi.spec
1131
+ ? getOpenApiOperationInfo(parsedOpenApi.spec, parsedOpenApi.kind, parsedOpenApi.method, parsedOpenApi.endpoint)
1132
+ : null;
1133
+ if (operationInfo?.excluded) return null;
1134
+
1135
+ const slug = slugFromOpenApiOperation(parsedOpenApi.method, parsedOpenApi.endpoint);
1136
+ const dest = uniqueDestination(`${destDir}/${slug}`);
1137
+ return {
1138
+ src: item,
1139
+ dest,
1140
+ kind: 'openapi-operation',
1141
+ openapiSpec: parsedOpenApi.spec,
1142
+ openapiMethod: parsedOpenApi.method,
1143
+ openapiEndpoint: parsedOpenApi.endpoint,
1144
+ openapiKind: parsedOpenApi.kind,
1145
+ title: operationInfo?.title,
1146
+ description: operationInfo?.description,
1147
+ deprecated: operationInfo?.deprecated,
1148
+ version: operationInfo?.version,
1149
+ content: operationInfo?.content,
1150
+ hidden: operationInfo?.hidden === true,
1151
+ };
260
1152
  }
261
1153
 
262
- return artifacts.pageMap;
263
- }
1154
+ function toAsyncApiPageMapping(item, destDir, inheritedSpec, mode = 'channel-only') {
1155
+ const parsedAsyncApi = parseAsyncApiChannelRef(item, inheritedSpec);
1156
+ if (!parsedAsyncApi) return mode === 'channel-only' ? undefined : null;
1157
+
1158
+ const channelInfo = parsedAsyncApi.spec
1159
+ ? getAsyncApiChannelInfo(parsedAsyncApi.spec, parsedAsyncApi.channel)
1160
+ : null;
1161
+ const resolvedChannel = channelInfo?.channel ?? parsedAsyncApi.channel;
1162
+ const slug = slugFromOpenApiOperation('channel', resolvedChannel);
1163
+ const dest = uniqueDestination(`${destDir}/${slug}`);
1164
+ return {
1165
+ src: item,
1166
+ dest,
1167
+ kind: 'asyncapi-channel',
1168
+ asyncapiSpec: parsedAsyncApi.spec,
1169
+ asyncapiChannel: resolvedChannel,
1170
+ title: channelInfo?.title ?? parsedAsyncApi.channel,
1171
+ description: channelInfo?.description,
1172
+ };
1173
+ }
264
1174
 
265
- let pageMap = rebuildFromConfig();
1175
+ function resolveInheritedVersion(value, inherited) {
1176
+ if (typeof value === 'string' && value.trim().length > 0) return value.trim();
1177
+ return inherited;
1178
+ }
266
1179
 
267
- function syncMarkdownFile(filename) {
268
- const srcSlug = filename.replace(/\\/g, '/').replace(/\.md$/, '');
269
- const srcPath = join(docsDir, `${srcSlug}.md`);
1180
+ function toPageMappingWithVersion(item, destDir, inheritedSpec, inheritedVersion, mode = 'file-fallback') {
1181
+ const mapping = toPageMapping(item, destDir, inheritedSpec, mode);
1182
+ if (!mapping) return null;
1183
+ if (mapping.kind === 'openapi-operation' && mapping.version === undefined) {
1184
+ mapping.version = inheritedVersion;
1185
+ }
1186
+ return mapping;
1187
+ }
270
1188
 
271
- if (!existsSync(srcPath)) {
272
- pageMap = rebuildFromConfig();
273
- return;
1189
+ function toAsyncApiPageMappingWithVersion(item, destDir, inheritedSpec, inheritedVersion, mode = 'channel-only') {
1190
+ const mapping = toAsyncApiPageMapping(item, destDir, inheritedSpec, mode);
1191
+ if (!mapping) return null;
1192
+ if (mapping.version === undefined) mapping.version = inheritedVersion;
1193
+ return mapping;
274
1194
  }
275
1195
 
276
- const matches = pageMap.filter((entry) => entry.src === srcSlug);
277
- if (matches.length === 0) return;
1196
+ function toOperationMapping(ref, destDir, inheritedVersion) {
1197
+ const slug = slugFromOpenApiOperation(ref.method, ref.endpoint);
1198
+ const dest = uniqueDestination(`${destDir}/${slug}`);
1199
+ return {
1200
+ src: `${ref.spec ? `${ref.spec} ` : ''}${ref.method} ${ref.endpoint}`,
1201
+ dest,
1202
+ kind: 'openapi-operation',
1203
+ openapiSpec: ref.spec,
1204
+ openapiMethod: ref.method,
1205
+ openapiEndpoint: ref.endpoint,
1206
+ openapiKind: ref.kind,
1207
+ title: ref.title,
1208
+ description: ref.description,
1209
+ deprecated: ref.deprecated,
1210
+ version: ref.version ?? inheritedVersion,
1211
+ content: ref.content,
1212
+ hidden: ref.hidden === true,
1213
+ };
1214
+ }
278
1215
 
279
- for (const match of matches) {
280
- const destPath = join(contentDir, `${match.dest}.mdx`);
281
- processPage(srcPath, destPath, srcSlug);
1216
+ function buildOpenApiMappings(openapi, destDir, fallbackSpec, inheritedVersion) {
1217
+ const specs = resolveOpenApiSpecList(openapi);
1218
+ if (specs.length === 0 && fallbackSpec) specs.push(fallbackSpec);
1219
+ if (specs.length === 0) return [];
1220
+
1221
+ const output = [];
1222
+ const seen = new Set();
1223
+ for (const spec of specs) {
1224
+ for (const operation of loadOpenApiOperations(spec)) {
1225
+ const key = `${operation.spec ?? ''}::${operation.kind ?? 'path'}::${operation.method}::${operation.endpoint}`;
1226
+ if (seen.has(key)) continue;
1227
+ seen.add(key);
1228
+ output.push(toOperationMapping(operation, destDir, inheritedVersion));
1229
+ }
1230
+ }
1231
+ return output;
282
1232
  }
283
1233
 
284
- console.log(' \x1b[32m↻\x1b[0m ' + srcSlug);
285
- }
1234
+ function buildAsyncApiMappings(asyncapi, destDir, fallbackSpec, inheritedVersion) {
1235
+ const specs = resolveAsyncApiSpecList(asyncapi);
1236
+ if (specs.length === 0 && fallbackSpec) specs.push(fallbackSpec);
1237
+ if (specs.length === 0) return [];
1238
+
1239
+ const output = [];
1240
+ const seen = new Set();
1241
+ for (const spec of specs) {
1242
+ for (const channel of loadAsyncApiChannels(spec)) {
1243
+ const key = `${channel.spec ?? ''}::${channel.channel}`;
1244
+ if (seen.has(key)) continue;
1245
+ seen.add(key);
1246
+ const slug = slugFromOpenApiOperation('channel', channel.channel);
1247
+ const dest = uniqueDestination(`${destDir}/${slug}`);
1248
+ output.push({
1249
+ src: `${channel.spec ? `${channel.spec} ` : ''}${channel.channel}`,
1250
+ dest,
1251
+ kind: 'asyncapi-channel',
1252
+ asyncapiSpec: channel.spec,
1253
+ asyncapiChannel: channel.channel,
1254
+ title: channel.title,
1255
+ description: channel.description,
1256
+ version: inheritedVersion,
1257
+ });
1258
+ }
1259
+ }
1260
+ return output;
1261
+ }
286
1262
 
287
- function syncConfig() {
288
- const srcPath = join(docsDir, 'velu.json');
289
- copyFileSync(srcPath, resolve('velu.json'));
290
- pageMap = rebuildFromConfig();
291
- console.log(' \x1b[32m↻\x1b[0m velu.json updated (navigation/content synced)');
292
- }
1263
+ function addGroup(group, parentDir, inheritedOpenApiSpec, inheritedVersion, inheritedAsyncApiSpec) {
1264
+ const groupDir = `${parentDir}/${group.slug}`;
1265
+ const pages = [];
1266
+ const groupOpenApiSpec = resolveDefaultOpenApiSpec(group.openapi) ?? inheritedOpenApiSpec;
1267
+ const groupAsyncApiSpec = resolveDefaultAsyncApiSpec(group.asyncapi) ?? inheritedAsyncApiSpec;
1268
+ const groupVersion = resolveInheritedVersion(group.version, inheritedVersion);
1269
+ const groupPageItems = Array.isArray(group.pages) ? group.pages : [];
293
1270
 
294
- function startWatcher() {
295
- const debounce = new Map();
1271
+ for (const item of groupPageItems) {
1272
+ if (typeof item === 'string') {
1273
+ const parsedOpenApiRef = parseOpenApiOperationRef(item, groupOpenApiSpec);
1274
+ if (parsedOpenApiRef) {
1275
+ const openApiMapping = toPageMappingWithVersion(item, groupDir, groupOpenApiSpec, groupVersion, 'operation-only');
1276
+ if (!openApiMapping) continue;
1277
+ pageMap.push(openApiMapping);
1278
+ const pageEntry = metaEntryForDestination(groupDir, openApiMapping.dest);
1279
+ pages.push(openApiMapping.hidden ? `!${pageEntry}` : pageEntry);
1280
+ trackFirstPage(openApiMapping.dest, openApiMapping.hidden);
1281
+ continue;
1282
+ }
296
1283
 
297
- watch(docsDir, { recursive: true }, (_, rawFilename) => {
298
- if (!rawFilename) return;
299
- const filename = rawFilename.replace(/\\/g, '/');
1284
+ const asyncMapping = toAsyncApiPageMappingWithVersion(item, groupDir, groupAsyncApiSpec, groupVersion, 'channel-only');
1285
+ if (asyncMapping) {
1286
+ pageMap.push(asyncMapping);
1287
+ const pageEntry = metaEntryForDestination(groupDir, asyncMapping.dest);
1288
+ pages.push(asyncMapping.hidden ? `!${pageEntry}` : pageEntry);
1289
+ trackFirstPage(asyncMapping.dest, asyncMapping.hidden);
1290
+ continue;
1291
+ }
300
1292
 
301
- if (filename.startsWith('.velu-out/')) return;
302
- if (filename.includes('node_modules')) return;
303
- if (filename.startsWith('.')) return;
1293
+ const fileMapping = toFilePageMapping(item, groupDir);
1294
+ pageMap.push(fileMapping);
1295
+ const pageEntry = metaEntryForDestination(groupDir, fileMapping.dest);
1296
+ pages.push(pageEntry);
1297
+ trackFirstPage(fileMapping.dest, false);
1298
+ } else if (isGroup(item)) {
1299
+ addGroup(item, groupDir, groupOpenApiSpec, groupVersion, groupAsyncApiSpec);
1300
+ pages.push(item.hidden ? `!${item.slug}` : item.slug);
1301
+ } else if (isSeparator(item)) {
1302
+ pages.push(`---${item.separator}---`);
1303
+ } else if (isLink(item)) {
1304
+ pages.push(
1305
+ item.icon
1306
+ ? `[${item.icon}][${item.label}](${item.href})`
1307
+ : `[${item.label}](${item.href})`
1308
+ );
1309
+ }
1310
+ }
304
1311
 
305
- if (debounce.has(filename)) clearTimeout(debounce.get(filename));
306
- debounce.set(
307
- filename,
308
- setTimeout(() => {
309
- debounce.delete(filename);
1312
+ if (groupPageItems.length === 0 && group.openapi !== undefined) {
1313
+ const generatedDestDir = resolveGenerationDestination(group.openapi, groupDir);
1314
+ const generatedMappings = buildOpenApiMappings(group.openapi, generatedDestDir, groupOpenApiSpec, groupVersion);
1315
+ for (const mapping of generatedMappings) {
1316
+ pageMap.push(mapping);
1317
+ const pageEntry = metaEntryForDestination(groupDir, mapping.dest);
1318
+ pages.push(mapping.hidden ? `!${pageEntry}` : pageEntry);
1319
+ trackFirstPage(mapping.dest, mapping.hidden);
1320
+ }
1321
+ }
310
1322
 
311
- try {
312
- if (filename === 'velu.json') {
313
- syncConfig();
314
- return;
315
- }
1323
+ if (groupPageItems.length === 0 && group.asyncapi !== undefined) {
1324
+ const generatedDestDir = resolveGenerationDestination(group.asyncapi, groupDir);
1325
+ const generatedMappings = buildAsyncApiMappings(group.asyncapi, generatedDestDir, groupAsyncApiSpec, groupVersion);
1326
+ for (const mapping of generatedMappings) {
1327
+ pageMap.push(mapping);
1328
+ const pageEntry = metaEntryForDestination(groupDir, mapping.dest);
1329
+ pages.push(mapping.hidden ? `!${pageEntry}` : pageEntry);
1330
+ trackFirstPage(mapping.dest, mapping.hidden);
1331
+ }
1332
+ }
1333
+
1334
+ const groupMeta = {
1335
+ title: group.group,
1336
+ pages,
1337
+ defaultOpen: group.expanded !== false,
1338
+ };
1339
+
1340
+ if (group.icon) groupMeta.icon = group.icon;
1341
+ if (group.iconType) groupMeta.iconType = group.iconType;
1342
+ if (group.description) groupMeta.description = group.description;
1343
+
1344
+ metaFiles.push({ dir: groupDir, data: groupMeta });
1345
+ }
1346
+
1347
+ for (const tab of rootTabs) {
1348
+ const tabPages = [];
1349
+ const tabOpenApiSpec = resolveDefaultOpenApiSpec(tab.openapi) ?? defaultOpenApiSpec;
1350
+ const tabAsyncApiSpec = resolveDefaultAsyncApiSpec(tab.asyncapi) ?? defaultAsyncApiSpec;
1351
+ const tabVersion = resolveInheritedVersion(tab.version);
1352
+ const tabGroups = Array.isArray(tab.groups) ? tab.groups : [];
1353
+ const tabPageItems = Array.isArray(tab.pages) ? tab.pages : [];
1354
+
1355
+ for (const group of tabGroups) {
1356
+ addGroup(group, tab.slug, tabOpenApiSpec, tabVersion, tabAsyncApiSpec);
1357
+ tabPages.push(group.hidden ? `!${group.slug}` : group.slug);
1358
+ }
316
1359
 
317
- if (extname(filename) === '.md') {
318
- syncMarkdownFile(filename);
319
- }
320
- } catch (error) {
321
- console.error(' \x1b[31m✗\x1b[0m Failed to sync ' + filename + ': ' + error.message);
1360
+ for (const item of tabPageItems) {
1361
+ if (typeof item === 'string') {
1362
+ const parsedOpenApiRef = parseOpenApiOperationRef(item, tabOpenApiSpec);
1363
+ if (parsedOpenApiRef) {
1364
+ const openApiMapping = toPageMappingWithVersion(item, tab.slug, tabOpenApiSpec, tabVersion, 'operation-only');
1365
+ if (!openApiMapping) continue;
1366
+ pageMap.push(openApiMapping);
1367
+ const pageEntry = metaEntryForDestination(tab.slug, openApiMapping.dest);
1368
+ tabPages.push(openApiMapping.hidden ? `!${pageEntry}` : pageEntry);
1369
+ trackFirstPage(openApiMapping.dest, openApiMapping.hidden);
1370
+ continue;
322
1371
  }
323
- }, 120)
324
- );
325
- });
1372
+
1373
+ const asyncMapping = toAsyncApiPageMappingWithVersion(item, tab.slug, tabAsyncApiSpec, tabVersion, 'channel-only');
1374
+ if (asyncMapping) {
1375
+ pageMap.push(asyncMapping);
1376
+ const pageEntry = metaEntryForDestination(tab.slug, asyncMapping.dest);
1377
+ tabPages.push(asyncMapping.hidden ? `!${pageEntry}` : pageEntry);
1378
+ trackFirstPage(asyncMapping.dest, asyncMapping.hidden);
1379
+ continue;
1380
+ }
1381
+
1382
+ const fileMapping = toFilePageMapping(item, tab.slug);
1383
+ pageMap.push(fileMapping);
1384
+ const pageEntry = metaEntryForDestination(tab.slug, fileMapping.dest);
1385
+ tabPages.push(pageEntry);
1386
+ trackFirstPage(fileMapping.dest, false);
1387
+ } else {
1388
+ tabPages.push(metaEntry(item));
1389
+ }
1390
+ }
1391
+
1392
+ if (tabGroups.length === 0 && tabPageItems.length === 0 && tab.openapi !== undefined) {
1393
+ const generatedDestDir = resolveGenerationDestination(tab.openapi, tab.slug);
1394
+ const generatedMappings = buildOpenApiMappings(tab.openapi, generatedDestDir, tabOpenApiSpec, tabVersion);
1395
+ for (const mapping of generatedMappings) {
1396
+ pageMap.push(mapping);
1397
+ const pageEntry = metaEntryForDestination(tab.slug, mapping.dest);
1398
+ tabPages.push(mapping.hidden ? `!${pageEntry}` : pageEntry);
1399
+ trackFirstPage(mapping.dest, mapping.hidden);
1400
+ }
1401
+ }
1402
+
1403
+ if (tabGroups.length === 0 && tabPageItems.length === 0 && tab.asyncapi !== undefined) {
1404
+ const generatedDestDir = resolveGenerationDestination(tab.asyncapi, tab.slug);
1405
+ const generatedMappings = buildAsyncApiMappings(tab.asyncapi, generatedDestDir, tabAsyncApiSpec, tabVersion);
1406
+ for (const mapping of generatedMappings) {
1407
+ pageMap.push(mapping);
1408
+ const pageEntry = metaEntryForDestination(tab.slug, mapping.dest);
1409
+ tabPages.push(mapping.hidden ? `!${pageEntry}` : pageEntry);
1410
+ trackFirstPage(mapping.dest, mapping.hidden);
1411
+ }
1412
+ }
1413
+
1414
+ const tabMeta = {
1415
+ title: tab.tab,
1416
+ root: true,
1417
+ pages: tabPages,
1418
+ };
1419
+
1420
+ if (tab.icon) tabMeta.icon = tab.icon;
1421
+ if (tab.iconType) tabMeta.iconType = tab.iconType;
1422
+
1423
+ metaFiles.push({ dir: tab.slug, data: tabMeta });
1424
+ }
1425
+
1426
+ if (rootPages.length > 0) {
1427
+ metaFiles.push({ dir: '', data: { pages: rootPages } });
1428
+ }
1429
+
1430
+ if (!hasFirstPage && firstHiddenPageCandidate) {
1431
+ firstPage = firstHiddenPageCandidate;
1432
+ hasFirstPage = true;
1433
+ }
1434
+
1435
+ return { pageMap, metaFiles, firstPage };
326
1436
  }
1437
+
1438
+ function processPage(srcPath, destPath, slug) {
1439
+ let content = readFileSync(srcPath, 'utf-8');
1440
+ if (!content.startsWith('---')) {
1441
+ const titleMatch = content.match(/^#\s+(.+)$/m);
1442
+ const title = titleMatch ? titleMatch[1] : pageLabelFromSlug(slug);
1443
+ if (titleMatch) {
1444
+ content = content.replace(/^#\s+.+$/m, '').trimStart();
1445
+ }
1446
+ content = `---\ntitle: "${title}"\n---\n\n${content}`;
1447
+ }
1448
+ content = rewriteImportsInContent(content, srcPath, destPath);
1449
+
1450
+ mkdirSync(dirname(destPath), { recursive: true });
1451
+ writeFileSync(destPath, content, 'utf-8');
1452
+ }
1453
+
1454
+ function writeMetaFiles(metaFiles) {
1455
+ for (const meta of metaFiles) {
1456
+ const metaPath = join(contentDir, meta.dir, 'meta.json');
1457
+ mkdirSync(dirname(metaPath), { recursive: true });
1458
+ writeFileSync(metaPath, JSON.stringify(meta.data, null, 2) + '\n', 'utf-8');
1459
+ }
1460
+ }
1461
+
1462
+ function writeIndexPage(firstPage) {
1463
+ writeFileSync(
1464
+ join(contentDir, 'index.mdx'),
1465
+ `---\ntitle: "Overview"\ndescription: Documentation powered by Velu\n---\n\nimport { Card, Cards } from "fumadocs-ui/components/card"\nimport { Callout } from "fumadocs-ui/components/callout"\n\n<Callout type="info">\n Welcome to your documentation site.\n</Callout>\n\n## Start here\n\n<Cards>\n <Card\n title="Read the docs"\n href="/${firstPage}/"\n description="Begin with the first page in your configured navigation."\n />\n</Cards>\n`,
1466
+ 'utf-8'
1467
+ );
1468
+ }
1469
+
1470
+ function writeLangContent(langCode, artifacts, isDefault, useLangFolders = false) {
1471
+ const storagePrefix = useLangFolders ? langCode : (isDefault ? '' : langCode);
1472
+ const urlPrefix = isDefault ? '' : langCode;
1473
+
1474
+ // Write meta files (prefixed for non-default)
1475
+ const metaFiles = storagePrefix
1476
+ ? artifacts.metaFiles.map((meta) => ({
1477
+ dir: meta.dir ? `${storagePrefix}/${meta.dir}` : storagePrefix,
1478
+ data: { ...meta.data },
1479
+ }))
1480
+ : artifacts.metaFiles;
1481
+ writeMetaFiles(metaFiles);
1482
+
1483
+ function sanitizeFrontmatterValue(value) {
1484
+ return String(value).replace(/\r?\n+/g, ' ').replace(/"/g, '\\"').trim();
1485
+ }
327
1486
 
328
- function runNext(command, port) {
1487
+ // Copy pages using explicit source paths from docs.json/velu.json
1488
+ for (const mapping of artifacts.pageMap) {
1489
+ const destPath = join(
1490
+ contentDir,
1491
+ storagePrefix ? `${storagePrefix}/${mapping.dest}.mdx` : `${mapping.dest}.mdx`,
1492
+ );
1493
+
1494
+ if (mapping.kind === 'openapi-operation') {
1495
+ mkdirSync(dirname(destPath), { recursive: true });
1496
+ const operationLabel = `${mapping.openapiMethod || 'GET'} ${mapping.openapiEndpoint || '/'}`;
1497
+ const normalizedSpec = normalizeOpenApiSpecForFrontmatter(mapping.openapiSpec);
1498
+ const openapiValue = normalizedSpec
1499
+ ? `${normalizedSpec} ${operationLabel}`
1500
+ : operationLabel;
1501
+ const title = sanitizeFrontmatterValue(mapping.title ?? operationLabel);
1502
+ const description = typeof mapping.description === 'string'
1503
+ ? sanitizeFrontmatterValue(mapping.description)
1504
+ : '';
1505
+ const version = typeof mapping.version === 'string'
1506
+ ? sanitizeFrontmatterValue(mapping.version)
1507
+ : '';
1508
+ const openapi = openapiValue.replace(/"/g, '\\"');
1509
+ const warning = normalizedSpec
1510
+ ? ''
1511
+ : '\n> Warning: No OpenAPI spec source was resolved for this operation. Set `openapi` on this tab/group/navigation or at the top level.\n';
1512
+ const descriptionLine = description ? `\ndescription: "${description}"` : '';
1513
+ const deprecatedLine = mapping.deprecated === true ? `\ndeprecated: true` : '';
1514
+ const statusLine = mapping.deprecated === true ? `\nstatus: "deprecated"` : '';
1515
+ const versionLine = version ? `\nversion: "${version}"` : '';
1516
+ const content = typeof mapping.content === 'string' ? `${mapping.content.trim()}\n` : '';
1517
+ writeFileSync(
1518
+ destPath,
1519
+ `---\ntitle: "${title}"${descriptionLine}${deprecatedLine}${statusLine}${versionLine}\nopenapi: "${openapi}"\n---\n${warning}${content}`,
1520
+ 'utf-8',
1521
+ );
1522
+ continue;
1523
+ }
1524
+
1525
+ if (mapping.kind === 'asyncapi-channel') {
1526
+ mkdirSync(dirname(destPath), { recursive: true });
1527
+ const channelLabel = `${mapping.asyncapiChannel || 'channel'}`;
1528
+ const normalizedSpec = normalizeOpenApiSpecForFrontmatter(mapping.asyncapiSpec);
1529
+ const asyncapiValue = normalizedSpec
1530
+ ? `${normalizedSpec} ${channelLabel}`
1531
+ : channelLabel;
1532
+ const title = sanitizeFrontmatterValue(mapping.title ?? channelLabel);
1533
+ const description = typeof mapping.description === 'string'
1534
+ ? sanitizeFrontmatterValue(mapping.description)
1535
+ : '';
1536
+ const version = typeof mapping.version === 'string'
1537
+ ? sanitizeFrontmatterValue(mapping.version)
1538
+ : '';
1539
+ const asyncapi = asyncapiValue.replace(/"/g, '\\"');
1540
+ const warning = normalizedSpec
1541
+ ? ''
1542
+ : '\n> Warning: No AsyncAPI spec source was resolved for this channel. Set `asyncapi` on this tab/group/navigation or at the top level.\n';
1543
+ const descriptionLine = description ? `\ndescription: "${description}"` : '';
1544
+ const versionLine = version ? `\nversion: "${version}"` : '';
1545
+ writeFileSync(
1546
+ destPath,
1547
+ `---\ntitle: "${title}"${descriptionLine}${versionLine}\nasyncapi: "${asyncapi}"\n---\n${warning}`,
1548
+ 'utf-8',
1549
+ );
1550
+ continue;
1551
+ }
1552
+
1553
+ const src = mapping.src;
1554
+ let srcPath = join(docsDir, `${src}.mdx`);
1555
+ let ext = '.mdx';
1556
+ if (!existsSync(srcPath)) {
1557
+ srcPath = join(docsDir, `${src}.md`);
1558
+ ext = '.md';
1559
+ }
1560
+ if (!existsSync(srcPath)) {
1561
+ console.warn(` \x1b[33mWarning\x1b[0m Missing page source: ${src}${ext} (language: ${langCode})`);
1562
+ continue;
1563
+ }
1564
+ processPage(srcPath, destPath, src);
1565
+ }
1566
+
1567
+ // Index page
1568
+ const href = urlPrefix ? `/${urlPrefix}/${artifacts.firstPage}/` : `/${artifacts.firstPage}/`;
1569
+ const indexPath = storagePrefix ? join(contentDir, storagePrefix, 'index.mdx') : join(contentDir, 'index.mdx');
1570
+ writeFileSync(
1571
+ indexPath,
1572
+ `---\ntitle: "Overview"\ndescription: Documentation powered by Velu\n---\n\nimport { Card, Cards } from "fumadocs-ui/components/card"\nimport { Callout } from "fumadocs-ui/components/callout"\n\n<Callout type="info">\n Welcome to your documentation site.\n</Callout>\n\n## Start here\n\n<Cards>\n <Card\n title="Read the docs"\n href="${href}"\n description="Begin with the first page in your configured navigation."\n />\n</Cards>\n`,
1573
+ 'utf-8'
1574
+ );
1575
+ }
1576
+
1577
+ function rebuildFromConfig() {
1578
+ const config = loadConfig();
1579
+ const navLanguages = config.navigation?.languages;
1580
+ const simpleLanguages = config.languages || [];
1581
+
1582
+ rmSync(contentDir, { recursive: true, force: true });
1583
+ mkdirSync(contentDir, { recursive: true });
1584
+ writeRedirectArtifacts(config);
1585
+ rebuildSourceMirror();
1586
+
1587
+ // ── Mode 1: Per-language navigation (Mintlify-style) ──────────────
1588
+ if (navLanguages && navLanguages.length > 0) {
1589
+ const rootPages = [];
1590
+
1591
+ for (let i = 0; i < navLanguages.length; i++) {
1592
+ const langEntry = navLanguages[i];
1593
+ const langCode = langEntry.language;
1594
+ const isDefault = i === 0;
1595
+
1596
+ // Build artifacts using this language's own tabs
1597
+ const langConfig = { ...config, navigation: { ...config.navigation, tabs: langEntry.tabs } };
1598
+ const artifacts = buildArtifacts(langConfig);
1599
+
1600
+ writeLangContent(langCode, artifacts, isDefault, true);
1601
+ rootPages.push(`!${langCode}`);
1602
+ }
1603
+
1604
+ // Write root meta with default tabs + hidden language folders
1605
+ writeFileSync(
1606
+ join(contentDir, 'meta.json'),
1607
+ JSON.stringify({ pages: rootPages }, null, 2) + '\n',
1608
+ 'utf-8'
1609
+ );
1610
+
1611
+ // Return the default language's page map for file watching
1612
+ const defaultConfig = { ...config, navigation: { ...config.navigation, tabs: navLanguages[0].tabs } };
1613
+ const generatedPageMap = buildArtifacts(defaultConfig).pageMap;
1614
+ generateOgImages(config);
1615
+ return generatedPageMap;
1616
+ }
1617
+
1618
+ // ── Mode 2: Simple multi-lang (same nav, content in docs/<lang>/) ─
1619
+ const artifacts = buildArtifacts(config);
1620
+
1621
+ const useLangFolders = simpleLanguages.length > 1;
1622
+ writeLangContent(simpleLanguages[0] || 'en', artifacts, true, useLangFolders);
1623
+
1624
+ if (simpleLanguages.length > 1) {
1625
+ const rootMetaPath = join(contentDir, 'meta.json');
1626
+ const rootPages = [`!${simpleLanguages[0] || 'en'}`];
1627
+
1628
+ for (const lang of simpleLanguages.slice(1)) {
1629
+ writeLangContent(lang, artifacts, false, true);
1630
+ rootPages.push(`!${lang}`);
1631
+ }
1632
+
1633
+ writeFileSync(rootMetaPath, JSON.stringify({ pages: rootPages }, null, 2) + '\n', 'utf-8');
1634
+ }
1635
+
1636
+ generateOgImages(config);
1637
+ return artifacts.pageMap;
1638
+ }
1639
+
1640
+ let pageMap = rebuildFromConfig();
1641
+ copyStaticAssets();
1642
+
1643
+ function syncMarkdownFile(filename) {
1644
+ syncSourceMirrorFile(filename);
1645
+ const srcSlug = filename.replace(/\\/g, '/').replace(/\.(md|mdx)$/, '');
1646
+ let srcPath = join(docsDir, `${srcSlug}.mdx`);
1647
+ if (!existsSync(srcPath)) {
1648
+ srcPath = join(docsDir, `${srcSlug}.md`);
1649
+ }
1650
+
1651
+ if (!existsSync(srcPath)) {
1652
+ pageMap = rebuildFromConfig();
1653
+ return;
1654
+ }
1655
+
1656
+ const matches = pageMap.filter((entry) => entry.src === srcSlug);
1657
+ if (matches.length === 0) return;
1658
+
1659
+ for (const match of matches) {
1660
+ const destPath = join(contentDir, `${match.dest}.mdx`);
1661
+ processPage(srcPath, destPath, srcSlug);
1662
+ }
1663
+
1664
+ generateOgImages(loadConfig());
1665
+
1666
+ console.log(' \x1b[32m↻\x1b[0m ' + srcSlug);
1667
+ }
1668
+
1669
+ function syncConfig() {
1670
+ const srcPath = resolveConfigPath();
1671
+ copyFileSync(srcPath, resolve(PRIMARY_CONFIG_NAME));
1672
+ copyFileSync(srcPath, resolve(LEGACY_CONFIG_NAME));
1673
+ pageMap = rebuildFromConfig();
1674
+ copyStaticAssets();
1675
+ console.log(' \x1b[32m↻\x1b[0m docs.json/velu.json updated (navigation/content synced)');
1676
+ }
1677
+
1678
+ function startWatcher() {
1679
+ const debounce = new Map();
1680
+
1681
+ watch(docsDir, { recursive: true }, (_, rawFilename) => {
1682
+ if (!rawFilename) return;
1683
+ const filename = rawFilename.replace(/\\/g, '/');
1684
+
1685
+ if (filename.startsWith('.velu-out/')) return;
1686
+ if (filename.includes('node_modules')) return;
1687
+ if (filename.startsWith('.')) return;
1688
+
1689
+ if (debounce.has(filename)) clearTimeout(debounce.get(filename));
1690
+ debounce.set(
1691
+ filename,
1692
+ setTimeout(() => {
1693
+ debounce.delete(filename);
1694
+
1695
+ try {
1696
+ if (filename === PRIMARY_CONFIG_NAME || filename === LEGACY_CONFIG_NAME) {
1697
+ syncConfig();
1698
+ return;
1699
+ }
1700
+
1701
+ syncSourceMirrorFile(filename);
1702
+
1703
+ const ext = extname(filename);
1704
+ if (ext === '.md' || ext === '.mdx') {
1705
+ syncMarkdownFile(filename);
1706
+ return;
1707
+ }
1708
+
1709
+ if (isStaticAsset(filename)) {
1710
+ const src = join(docsDir, filename);
1711
+ const dest = join(publicDir, filename);
1712
+ if (existsSync(src)) {
1713
+ mkdirSync(dirname(dest), { recursive: true });
1714
+ copyFileSync(src, dest);
1715
+ generateOgImages(loadConfig());
1716
+ console.log(' \x1b[32m↻\x1b[0m ' + filename);
1717
+ } else {
1718
+ rmSync(dest, { force: true });
1719
+ generateOgImages(loadConfig());
1720
+ console.log(' \x1b[32m↻\x1b[0m removed ' + filename);
1721
+ }
1722
+ }
1723
+ } catch (error) {
1724
+ console.error(' \x1b[31m✗\x1b[0m Failed to sync ' + filename + ': ' + error.message);
1725
+ }
1726
+ }, 120)
1727
+ );
1728
+ });
1729
+ }
1730
+
1731
+ function runNext(command, port, envOverrides = {}) {
329
1732
  return new Promise((resolvePromise, rejectPromise) => {
330
1733
  const args = [nextBinPath, command];
331
1734
  if (command === 'dev' || command === 'start') {
332
1735
  args.push('--port', String(port));
333
1736
  }
334
-
1737
+
335
1738
  const child = spawn(process.execPath, args, {
336
1739
  cwd: '.',
337
1740
  stdio: 'inherit',
338
- env: process.env,
1741
+ env: { ...process.env, ...envOverrides },
339
1742
  });
340
-
341
- child.on('exit', (code) => {
342
- if (code === 0) resolvePromise();
343
- else rejectPromise(new Error(`${command} exited with ${code}`));
344
- });
345
- });
346
- }
347
-
348
- // ── CLI ──────────────────────────────────────────────────────────────────────
349
- const args = process.argv.slice(2);
350
- const command = args[0] || 'dev';
351
- const portIdx = args.indexOf('--port');
352
- const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : 4321;
353
-
354
- if (command === 'dev') {
355
- console.log('');
356
- console.log(' \x1b[36mvelu\x1b[0m dev server');
357
- console.log('');
358
- console.log(' watching for file changes...');
359
- startWatcher();
360
- await runNext('dev', port);
1743
+
1744
+ child.on('exit', (code) => {
1745
+ if (code === 0) resolvePromise();
1746
+ else rejectPromise(new Error(`${command} exited with ${code}`));
1747
+ });
1748
+ });
1749
+ }
1750
+
1751
+ // ── CLI ──────────────────────────────────────────────────────────────────────
1752
+ const args = process.argv.slice(2);
1753
+ const command = args[0] || 'dev';
1754
+ const portIdx = args.indexOf('--port');
1755
+ const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : 4321;
1756
+
1757
+ if (command === 'dev') {
1758
+ console.log('');
1759
+ console.log(' \x1b[36mvelu\x1b[0m dev server');
1760
+ console.log('');
1761
+ console.log(' watching for file changes...');
1762
+ startWatcher();
1763
+ await runNext('dev', port);
361
1764
  } else if (command === 'build') {
362
1765
  console.log('\n Building site...\n');
363
- await runNext('build', port);
364
-
365
- // Run Pagefind to index the static output for search
366
- console.log(' Indexing for search...');
367
- const pagefindBin = join(dirname(require.resolve('next/package.json')), '..', 'pagefind', 'lib', 'runner', 'bin.cjs');
368
- await new Promise((res, rej) => {
369
- const pf = spawn(process.execPath, [pagefindBin, '--site', 'dist', '--output-path', 'dist/pagefind'], {
370
- cwd: '.',
371
- stdio: 'inherit',
372
- });
373
- pf.on('exit', (code) => (code === 0 ? res() : rej(new Error(`pagefind exited with ${code}`))));
374
- });
375
-
376
- console.log('\n ✅ Site built successfully.\n');
377
- } else {
378
- console.error(`Unknown server command: ${command}`);
379
- process.exit(1);
380
- }
1766
+ // Static export cannot include API route handlers.
1767
+ // Keep proxy routes for dev, but remove them for production export build.
1768
+ rmSync(resolve('app', 'api'), { recursive: true, force: true });
1769
+ await runNext('build', port, { VELU_STATIC_EXPORT: '1' });
1770
+
1771
+ // Run Pagefind to index the static output for search
1772
+ console.log(' Indexing for search...');
1773
+ const pagefindBin = join(dirname(require.resolve('next/package.json')), '..', 'pagefind', 'lib', 'runner', 'bin.cjs');
1774
+ await new Promise((res, rej) => {
1775
+ const pf = spawn(process.execPath, [pagefindBin, '--site', 'dist', '--output-path', 'dist/pagefind'], {
1776
+ cwd: '.',
1777
+ stdio: 'inherit',
1778
+ });
1779
+ pf.on('exit', (code) => (code === 0 ? res() : rej(new Error(`pagefind exited with ${code}`))));
1780
+ });
1781
+
1782
+ console.log('\n ✅ Site built successfully.\n');
1783
+ } else {
1784
+ console.error(`Unknown server command: ${command}`);
1785
+ process.exit(1);
1786
+ }
1787
+