@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
package/src/build.ts CHANGED
@@ -1,244 +1,1225 @@
1
- import { readFileSync, writeFileSync, mkdirSync, copyFileSync, cpSync, existsSync, rmSync } from "node:fs";
2
- import { join, dirname } from "node:path";
1
+ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, cpSync, existsSync, rmSync, readdirSync } from "node:fs";
2
+ import { join, dirname, relative, extname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { parse as parseYaml } from "yaml";
4
5
  import { generateThemeCss, resolveThemeName, type VeluColors, type VeluStyling } from "./themes.js";
5
-
6
- // ── Engine directory (shipped with the CLI package) ──────────────────────────
7
- const __filename = fileURLToPath(import.meta.url);
8
- const __dirname = dirname(__filename);
9
- const ENGINE_DIR = join(__dirname, "engine");
10
-
11
- // ── Types (used only by build.ts for page copying) ─────────────────────────────
12
-
6
+ import { normalizeConfigNavigation } from "./navigation-normalize.js";
7
+
8
+ // ── Engine directory (shipped with the CLI package) ──────────────────────────
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const PACKAGED_ENGINE_DIR = join(__dirname, "engine");
12
+ const DEV_ENGINE_DIR = join(__dirname, "..", "src", "engine");
13
+ const ENGINE_DIR = existsSync(DEV_ENGINE_DIR) ? DEV_ENGINE_DIR : PACKAGED_ENGINE_DIR;
14
+ const PRIMARY_CONFIG_NAME = "docs.json";
15
+ const LEGACY_CONFIG_NAME = "velu.json";
16
+ const SOURCE_MIRROR_DIR = "velu-imports";
17
+
18
+ const SOURCE_MIRROR_EXTENSIONS = new Set([
19
+ ".md", ".mdx", ".jsx", ".js", ".tsx", ".ts",
20
+ ".json", ".yaml", ".yml", ".css",
21
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico",
22
+ ".woff", ".woff2", ".ttf", ".eot",
23
+ ".mp4", ".webm", ".mp3", ".wav",
24
+ ".pdf", ".txt", ".xml", ".csv", ".zip",
25
+ ]);
26
+
27
+ const IMPORT_REWRITE_EXTENSIONS = new Set([".md", ".mdx", ".jsx", ".js", ".tsx", ".ts"]);
28
+
29
+ function resolveConfigPath(docsDir: string): string {
30
+ const primary = join(docsDir, PRIMARY_CONFIG_NAME);
31
+ if (existsSync(primary)) return primary;
32
+ const legacy = join(docsDir, LEGACY_CONFIG_NAME);
33
+ if (existsSync(legacy)) return legacy;
34
+ throw new Error(`No ${PRIMARY_CONFIG_NAME} or ${LEGACY_CONFIG_NAME} found in ${docsDir}`);
35
+ }
36
+
37
+ // ── Types (used only by build.ts for page copying) ─────────────────────────────
38
+
39
+ interface VeluSeparator {
40
+ separator: string;
41
+ }
42
+
43
+ interface VeluLink {
44
+ href: string;
45
+ label: string;
46
+ icon?: string;
47
+ iconType?: string;
48
+ }
49
+
50
+ interface VeluAnchor {
51
+ anchor: string;
52
+ href?: string;
53
+ icon?: string;
54
+ iconType?: string;
55
+ openapi?: VeluOpenApiSource;
56
+ version?: string;
57
+ color?: {
58
+ light: string;
59
+ dark: string;
60
+ };
61
+ tabs?: VeluTab[];
62
+ hidden?: boolean;
63
+ }
64
+
65
+ interface VeluGlobalTab {
66
+ tab: string;
67
+ href: string;
68
+ icon?: string;
69
+ iconType?: string;
70
+ }
71
+
13
72
  interface VeluGroup {
14
73
  group: string;
15
74
  slug: string;
16
75
  icon?: string;
76
+ iconType?: string;
77
+ version?: string;
78
+ openapi?: VeluOpenApiSource;
17
79
  expanded?: boolean;
18
- pages: (string | VeluGroup)[];
19
- }
20
-
80
+ description?: string;
81
+ hidden?: boolean;
82
+ pages: (string | VeluGroup | VeluSeparator | VeluLink)[];
83
+ }
84
+
85
+ interface VeluMenuItem {
86
+ item: string;
87
+ icon?: string;
88
+ iconType?: string;
89
+ openapi?: VeluOpenApiSource;
90
+ groups?: VeluGroup[];
91
+ pages?: (string | VeluSeparator | VeluLink)[];
92
+ }
93
+
21
94
  interface VeluTab {
22
95
  tab: string;
23
96
  slug: string;
24
97
  icon?: string;
98
+ iconType?: string;
25
99
  href?: string;
26
- pages?: string[];
100
+ openapi?: VeluOpenApiSource;
101
+ version?: string;
102
+ pages?: (string | VeluSeparator | VeluLink)[];
27
103
  groups?: VeluGroup[];
104
+ menu?: VeluMenuItem[];
105
+ }
106
+
107
+ interface VeluLanguageNav {
108
+ language: string;
109
+ openapi?: VeluOpenApiSource;
110
+ tabs: VeluTab[];
111
+ }
112
+
113
+ interface VeluProductNav {
114
+ product: string;
115
+ icon?: string;
116
+ iconType?: string;
117
+ openapi?: VeluOpenApiSource;
118
+ tabs?: VeluTab[];
119
+ pages?: (string | VeluSeparator | VeluLink)[];
120
+ }
121
+
122
+ interface VeluVersionNav {
123
+ version: string;
124
+ openapi?: VeluOpenApiSource;
125
+ tabs: VeluTab[];
126
+ }
127
+
128
+ interface VeluRedirect {
129
+ source: string;
130
+ destination: string;
131
+ permanent?: boolean;
132
+ }
133
+
134
+ interface VeluConfig {
135
+ $schema?: string;
136
+ theme?: string;
137
+ colors?: VeluColors;
138
+ appearance?: "system" | "light" | "dark";
139
+ styling?: VeluStyling;
140
+ openapi?: VeluOpenApiSource;
141
+ languages?: string[];
142
+ redirects?: VeluRedirect[];
143
+ navigation: {
144
+ openapi?: VeluOpenApiSource;
145
+ tabs?: VeluTab[];
146
+ languages?: VeluLanguageNav[];
147
+ products?: VeluProductNav[];
148
+ versions?: VeluVersionNav[];
149
+ anchors?: VeluAnchor[];
150
+ global?: {
151
+ anchors?: VeluAnchor[];
152
+ tabs?: VeluGlobalTab[];
153
+ };
154
+ };
155
+ }
156
+
157
+ interface VeluOpenApiConfigObject {
158
+ source?: string | string[];
159
+ directory?: string;
28
160
  }
29
161
 
30
- interface VeluConfig {
31
- $schema?: string;
32
- theme?: string;
33
- colors?: VeluColors;
34
- appearance?: "system" | "light" | "dark";
35
- styling?: VeluStyling;
36
- navigation: {
37
- tabs: VeluTab[];
38
- };
162
+ type VeluOpenApiSource = string | string[] | VeluOpenApiConfigObject;
163
+
164
+ function isSeparator(item: unknown): item is VeluSeparator {
165
+ return typeof item === "object" && item !== null && "separator" in item;
166
+ }
167
+
168
+ function isLink(item: unknown): item is VeluLink {
169
+ return typeof item === "object" && item !== null && "href" in item && "label" in item;
170
+ }
171
+
172
+ function isGroup(item: unknown): item is VeluGroup {
173
+ return typeof item === "object" && item !== null && "group" in item;
174
+ }
175
+
176
+ const HTTP_METHODS = new Set([
177
+ "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "TRACE", "CONNECT", "WEBHOOK",
178
+ ]);
179
+ const OPENAPI_PATH_METHODS = new Set(["get", "post", "put", "patch", "delete", "options", "head", "trace"]);
180
+
181
+ interface ParsedOpenApiOperationRef {
182
+ spec?: string;
183
+ method: string;
184
+ endpoint: string;
185
+ kind?: "path" | "webhook";
186
+ title?: string;
187
+ description?: string;
188
+ deprecated?: boolean;
189
+ version?: string;
190
+ content?: string;
39
191
  }
40
192
 
41
- // ── Helpers ────────────────────────────────────────────────────────────────────
42
-
43
- function loadConfig(docsDir: string): VeluConfig {
44
- const raw = readFileSync(join(docsDir, "velu.json"), "utf-8");
45
- return JSON.parse(raw);
193
+ function resolveDefaultOpenApiSpec(openapi: VeluOpenApiSource | undefined): string | undefined {
194
+ const source = extractOpenApiSource(openapi);
195
+ if (typeof source === "string") {
196
+ const trimmed = source.trim();
197
+ return trimmed.length > 0 ? trimmed : undefined;
198
+ }
199
+ if (Array.isArray(source)) {
200
+ const first = source.find((entry) => typeof entry === "string" && entry.trim().length > 0);
201
+ return typeof first === "string" ? first.trim() : undefined;
202
+ }
203
+ return undefined;
46
204
  }
47
205
 
48
- function pageLabelFromSlug(slug: string): string {
49
- const last = slug.split("/").pop()!;
50
- return last.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
51
- }
206
+ function parseOpenApiOperationRef(value: string, inheritedSpec?: string): ParsedOpenApiOperationRef | null {
207
+ const trimmed = value.trim();
208
+ if (!trimmed) return null;
209
+
210
+ const withSpec = trimmed.match(/^(\S+)\s+([A-Za-z]+)\s+(.+)$/);
211
+ if (withSpec) {
212
+ const method = withSpec[2].toUpperCase();
213
+ const endpoint = withSpec[3].trim();
214
+ if (!HTTP_METHODS.has(method)) return null;
215
+ if (method === "WEBHOOK") {
216
+ if (!endpoint) return null;
217
+ return { spec: withSpec[1].trim(), method, endpoint, kind: "webhook" };
218
+ }
219
+ if (!endpoint.startsWith("/")) return null;
220
+ return { spec: withSpec[1].trim(), method, endpoint, kind: "path" };
221
+ }
52
222
 
53
- function pageBasename(page: string): string {
54
- return page.split("/").pop()!;
223
+ const noSpec = trimmed.match(/^([A-Za-z]+)\s+(.+)$/);
224
+ if (!noSpec) return null;
225
+ const method = noSpec[1].toUpperCase();
226
+ const endpoint = noSpec[2].trim();
227
+ if (!HTTP_METHODS.has(method)) return null;
228
+ if (method === "WEBHOOK") {
229
+ if (!endpoint) return null;
230
+ return { spec: inheritedSpec, method, endpoint, kind: "webhook" };
231
+ }
232
+ if (!endpoint.startsWith("/")) return null;
233
+ return { spec: inheritedSpec, method, endpoint, kind: "path" };
234
+ }
235
+
236
+ function slugFromOpenApiOperation(method: string, endpoint: string): string {
237
+ const cleaned = endpoint
238
+ .toLowerCase()
239
+ .replace(/^\/+/, "")
240
+ .replace(/[{}]/g, "")
241
+ .replace(/[^a-z0-9/._-]+/g, "-")
242
+ .replace(/\/+/g, "-")
243
+ .replace(/[-_.]{2,}/g, "-")
244
+ .replace(/^[-_.]+|[-_.]+$/g, "");
245
+ const body = cleaned || "endpoint";
246
+ return `${method.toLowerCase()}-${body}`;
55
247
  }
56
248
 
57
- interface PageMapping {
58
- src: string; // original page reference (file path without .md)
59
- dest: string; // destination path under content/docs (without extension)
249
+ function resolveOpenApiSpecList(openapi: VeluOpenApiSource | undefined): string[] {
250
+ const source = extractOpenApiSource(openapi);
251
+ if (typeof source === "string") {
252
+ const trimmed = source.trim();
253
+ return trimmed ? [trimmed] : [];
254
+ }
255
+ if (Array.isArray(source)) {
256
+ return source
257
+ .filter((entry): entry is string => typeof entry === "string")
258
+ .map((entry) => entry.trim())
259
+ .filter((entry) => entry.length > 0);
260
+ }
261
+ return [];
60
262
  }
61
263
 
62
- interface MetaFile {
63
- dir: string;
64
- data: Record<string, unknown>;
264
+ function extractOpenApiSource(openapi: VeluOpenApiSource | undefined): string | string[] | undefined {
265
+ if (typeof openapi === "string" || Array.isArray(openapi)) return openapi;
266
+ if (openapi && typeof openapi === "object") {
267
+ const source = (openapi as VeluOpenApiConfigObject).source;
268
+ if (typeof source === "string" || Array.isArray(source)) return source;
269
+ }
270
+ return undefined;
65
271
  }
66
272
 
67
- interface BuildArtifacts {
68
- pageMap: PageMapping[];
69
- metaFiles: MetaFile[];
70
- firstPage: string;
273
+ function resolveOpenApiDirectory(openapi: VeluOpenApiSource | undefined): string | undefined {
274
+ if (!openapi || typeof openapi !== "object" || Array.isArray(openapi)) return undefined;
275
+ const raw = (openapi as VeluOpenApiConfigObject).directory;
276
+ if (typeof raw !== "string") return undefined;
277
+ const trimmed = raw.trim().replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
278
+ return trimmed.length > 0 ? trimmed : undefined;
71
279
  }
72
280
 
73
- function buildArtifacts(config: VeluConfig): BuildArtifacts {
74
- const pageMap: PageMapping[] = [];
75
- const metaFiles: MetaFile[] = [];
76
- const rootTabs = config.navigation.tabs.filter((tab) => !tab.href);
77
- const rootPages = rootTabs.map((tab) => tab.slug);
78
- let firstPage = "quickstart";
79
- let hasFirstPage = false;
80
-
81
- function trackFirstPage(dest: string) {
82
- if (!hasFirstPage) {
83
- firstPage = dest;
84
- hasFirstPage = true;
85
- }
281
+ function parseOpenApiDocument(rawSource: string): Record<string, unknown> | null {
282
+ const source = rawSource.trim();
283
+ if (!source) return null;
284
+ try {
285
+ const parsed = JSON.parse(source);
286
+ if (parsed && typeof parsed === "object") return parsed as Record<string, unknown>;
287
+ } catch {
288
+ // fall through and attempt YAML parse.
289
+ }
290
+ try {
291
+ const parsed = parseYaml(source);
292
+ if (parsed && typeof parsed === "object") return parsed as Record<string, unknown>;
293
+ } catch {
294
+ return null;
86
295
  }
296
+ return null;
297
+ }
87
298
 
88
- function addGroup(group: VeluGroup, parentDir: string) {
89
- const groupDir = `${parentDir}/${group.slug}`;
90
- const pages: string[] = [];
299
+ function readMintMetadata(operation: Record<string, unknown>) {
300
+ const xMint = operation["x-mint"];
301
+ if (!xMint || typeof xMint !== "object") return {};
302
+ const metadata = (xMint as Record<string, unknown>).metadata;
303
+ const content = (xMint as Record<string, unknown>).content;
304
+ const meta = metadata && typeof metadata === "object" ? (metadata as Record<string, unknown>) : {};
305
+ return {
306
+ title: typeof meta.title === "string" ? meta.title : undefined,
307
+ description: typeof meta.description === "string" ? meta.description : undefined,
308
+ deprecated: typeof meta.deprecated === "boolean" ? meta.deprecated : undefined,
309
+ version: typeof meta.version === "string" ? meta.version : undefined,
310
+ content: typeof content === "string" ? content : undefined,
311
+ };
312
+ }
91
313
 
92
- for (const item of group.pages) {
93
- if (typeof item === "string") {
94
- const basename = pageBasename(item);
95
- const dest = `${groupDir}/${basename}`;
96
- pageMap.push({ src: item, dest });
97
- pages.push(basename);
98
- trackFirstPage(dest);
99
- } else {
100
- addGroup(item, groupDir);
101
- pages.push(item.slug);
102
- }
103
- }
314
+ function pickOperationMethod(pathItem: Record<string, unknown>): string | undefined {
315
+ for (const method of OPENAPI_PATH_METHODS) {
316
+ const operation = pathItem[method];
317
+ if (operation && typeof operation === "object") return method.toUpperCase();
318
+ }
319
+ return undefined;
320
+ }
104
321
 
105
- const groupMeta: Record<string, unknown> = {
106
- title: group.group,
107
- pages,
108
- defaultOpen: group.expanded !== false,
109
- };
322
+ function loadOpenApiOperations(specSource: string, docsDir: string): ParsedOpenApiOperationRef[] {
323
+ if (/^https?:\/\//i.test(specSource) || specSource.startsWith("file://")) return [];
110
324
 
111
- if (group.icon) groupMeta.icon = group.icon;
325
+ const resolvedPath = specSource.startsWith("/")
326
+ ? join(docsDir, specSource.replace(/^\/+/, ""))
327
+ : resolve(docsDir, specSource);
328
+ if (!existsSync(resolvedPath)) return [];
112
329
 
113
- metaFiles.push({ dir: groupDir, data: groupMeta });
114
- }
330
+ const parsed = parseOpenApiDocument(readFileSync(resolvedPath, "utf-8"));
331
+ if (!parsed) return [];
115
332
 
116
- for (const tab of rootTabs) {
117
- const tabPages: string[] = [];
333
+ const paths = parsed.paths;
334
+ const webhooks = parsed.webhooks;
118
335
 
119
- if (tab.groups) {
120
- for (const group of tab.groups) {
121
- addGroup(group, tab.slug);
122
- tabPages.push(group.slug);
336
+ const output: ParsedOpenApiOperationRef[] = [];
337
+ if (paths && typeof paths === "object") {
338
+ for (const [endpoint, methods] of Object.entries(paths as Record<string, unknown>)) {
339
+ if (!endpoint.startsWith("/") || !methods || typeof methods !== "object") continue;
340
+ for (const method of Object.keys(methods as Record<string, unknown>)) {
341
+ const normalized = method.toLowerCase();
342
+ if (!OPENAPI_PATH_METHODS.has(normalized)) continue;
343
+ const operation = (methods as Record<string, unknown>)[method];
344
+ if (!operation || typeof operation !== "object") continue;
345
+ if ((operation as Record<string, unknown>)["x-hidden"] === true) continue;
346
+ const mintMeta = readMintMetadata(operation as Record<string, unknown>);
347
+ output.push({
348
+ kind: "path",
349
+ spec: specSource,
350
+ method: normalized.toUpperCase(),
351
+ endpoint,
352
+ title: mintMeta.title ?? (typeof (operation as Record<string, unknown>).summary === "string" ? String((operation as Record<string, unknown>).summary) : undefined),
353
+ description: mintMeta.description ?? (typeof (operation as Record<string, unknown>).description === "string" ? String((operation as Record<string, unknown>).description) : undefined),
354
+ deprecated: mintMeta.deprecated ?? ((operation as Record<string, unknown>).deprecated === true),
355
+ version: mintMeta.version,
356
+ content: mintMeta.content,
357
+ });
123
358
  }
124
359
  }
360
+ }
125
361
 
126
- if (tab.pages) {
127
- for (const page of tab.pages) {
128
- const basename = pageBasename(page);
129
- const dest = `${tab.slug}/${basename}`;
130
- pageMap.push({ src: page, dest });
131
- tabPages.push(basename);
132
- trackFirstPage(dest);
133
- }
362
+ if (webhooks && typeof webhooks === "object") {
363
+ for (const [webhookName, pathItem] of Object.entries(webhooks as Record<string, unknown>)) {
364
+ if (!pathItem || typeof pathItem !== "object") continue;
365
+ const resolvedMethod = pickOperationMethod(pathItem as Record<string, unknown>);
366
+ if (!resolvedMethod) continue;
367
+ const operation = (pathItem as Record<string, unknown>)[resolvedMethod.toLowerCase()];
368
+ if (!operation || typeof operation !== "object") continue;
369
+ if ((operation as Record<string, unknown>)["x-hidden"] === true) continue;
370
+ const mintMeta = readMintMetadata(operation as Record<string, unknown>);
371
+ output.push({
372
+ kind: "webhook",
373
+ spec: specSource,
374
+ method: "WEBHOOK",
375
+ endpoint: webhookName,
376
+ title: mintMeta.title ?? (typeof (operation as Record<string, unknown>).summary === "string" ? String((operation as Record<string, unknown>).summary) : undefined),
377
+ description: mintMeta.description ?? (typeof (operation as Record<string, unknown>).description === "string" ? String((operation as Record<string, unknown>).description) : undefined),
378
+ deprecated: mintMeta.deprecated ?? ((operation as Record<string, unknown>).deprecated === true),
379
+ version: mintMeta.version,
380
+ content: mintMeta.content,
381
+ });
134
382
  }
135
-
136
- const tabMeta: Record<string, unknown> = {
137
- title: tab.tab,
138
- root: true,
139
- pages: tabPages,
140
- };
141
-
142
- if (tab.icon) tabMeta.icon = tab.icon;
143
-
144
- metaFiles.push({ dir: tab.slug, data: tabMeta });
145
383
  }
384
+ return output;
385
+ }
146
386
 
147
- if (rootPages.length > 0) {
148
- metaFiles.push({ dir: "", data: { pages: rootPages } });
387
+ function normalizeOpenApiSpecForFrontmatter(spec: string | undefined): string | undefined {
388
+ if (!spec) return undefined;
389
+ const trimmed = spec.trim();
390
+ if (!trimmed) return undefined;
391
+ if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith("file://")) return trimmed;
392
+ if (trimmed.startsWith("/")) return trimmed;
393
+ return `/${trimmed.replace(/^\.?\/*/, "")}`;
394
+ }
395
+
396
+ // ── Helpers ────────────────────────────────────────────────────────────────────
397
+
398
+ function loadConfig(docsDir: string): VeluConfig {
399
+ const raw = readFileSync(resolveConfigPath(docsDir), "utf-8");
400
+ const parsed = JSON.parse(raw) as VeluConfig;
401
+ return normalizeConfigNavigation(parsed);
402
+ }
403
+
404
+ function isExternalDestination(value: string): boolean {
405
+ return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value);
406
+ }
407
+
408
+ function normalizePath(value: string): string {
409
+ const trimmed = value.trim();
410
+ if (!trimmed) return "/";
411
+ const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
412
+ const collapsed = withLeadingSlash.replace(/\/{2,}/g, "/");
413
+ if (collapsed !== "/" && collapsed.endsWith("/")) return collapsed.slice(0, -1);
414
+ return collapsed;
415
+ }
416
+
417
+ function collectRedirectRules(config: VeluConfig): Array<{ source: string; destination: string; permanent: boolean }> {
418
+ const redirects = Array.isArray(config.redirects) ? config.redirects : [];
419
+ const output: Array<{ source: string; destination: string; permanent: boolean }> = [];
420
+
421
+ for (const redirect of redirects) {
422
+ if (!redirect || typeof redirect.source !== "string" || typeof redirect.destination !== "string") continue;
423
+ const source = redirect.source.trim();
424
+ const destination = redirect.destination.trim();
425
+ if (!source || !destination) continue;
426
+ if (/[?#]/.test(source) || /[?#]/.test(destination)) continue;
427
+
428
+ const normalizedSource = normalizePath(source);
429
+ const normalizedDestination = isExternalDestination(destination)
430
+ ? destination
431
+ : normalizePath(destination);
432
+ if (!isExternalDestination(normalizedDestination) && normalizedSource === normalizedDestination) continue;
433
+
434
+ output.push({
435
+ source: normalizedSource,
436
+ destination: normalizedDestination,
437
+ permanent: redirect.permanent !== false,
438
+ });
439
+ }
440
+
441
+ return output;
442
+ }
443
+
444
+ function writeRedirectArtifacts(config: VeluConfig, outDir: string) {
445
+ const redirects = collectRedirectRules(config);
446
+ const generatedDir = join(outDir, "generated");
447
+ mkdirSync(generatedDir, { recursive: true });
448
+
449
+ writeFileSync(
450
+ join(generatedDir, "redirects.ts"),
451
+ `const redirects: Array<{ source: string; destination: string; permanent: boolean }> = ${JSON.stringify(redirects, null, 2)};\n\nexport default redirects;\n`,
452
+ "utf-8"
453
+ );
454
+
455
+ const redirectsFilePath = join(outDir, "public", "_redirects");
456
+ if (redirects.length === 0) {
457
+ rmSync(redirectsFilePath, { force: true });
458
+ return;
459
+ }
460
+
461
+ const netlifyBody = redirects
462
+ .map((redirect) => `${redirect.source} ${redirect.destination} ${redirect.permanent ? 301 : 307}`)
463
+ .join("\n");
464
+ writeFileSync(redirectsFilePath, `${netlifyBody}\n`, "utf-8");
465
+ }
466
+
467
+ const STATIC_EXTENSIONS = new Set([
468
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico",
469
+ ".mp4", ".webm",
470
+ ".mp3", ".wav",
471
+ ".json", ".yaml", ".yml",
472
+ ".css",
473
+ ".js",
474
+ ".woff", ".woff2", ".ttf", ".eot",
475
+ ".pdf", ".txt",
476
+ ".xml", ".csv",
477
+ ".zip",
478
+ ]);
479
+
480
+ function copyStaticAssets(docsDir: string, publicDir: string) {
481
+ function walk(dir: string) {
482
+ const entries = readdirSync(dir, { withFileTypes: true });
483
+ for (const entry of entries) {
484
+ if (entry.name.startsWith(".")) continue;
485
+ if (entry.name === "node_modules") continue;
486
+ const srcPath = join(dir, entry.name);
487
+ if (entry.isDirectory()) {
488
+ walk(srcPath);
489
+ continue;
490
+ }
491
+
492
+ const ext = entry.name.includes(".")
493
+ ? `.${entry.name.split(".").pop()!.toLowerCase()}`
494
+ : "";
495
+ if (!STATIC_EXTENSIONS.has(ext)) continue;
496
+
497
+ const rel = relative(docsDir, srcPath);
498
+ const destPath = join(publicDir, rel);
499
+ mkdirSync(dirname(destPath), { recursive: true });
500
+ copyFileSync(srcPath, destPath);
501
+ }
502
+ }
503
+
504
+ walk(docsDir);
505
+ }
506
+
507
+ function toPosixPath(value: string): string {
508
+ return value.replace(/\\/g, "/");
509
+ }
510
+
511
+ function isInsideDocsRoot(docsDir: string, targetPath: string): boolean {
512
+ const relPath = relative(docsDir, targetPath);
513
+ if (!relPath) return true;
514
+ if (relPath.startsWith("..")) return false;
515
+ if (/^[a-zA-Z]:/.test(relPath)) return false;
516
+ return true;
517
+ }
518
+
519
+ function shouldMirrorSourceFile(filePath: string): boolean {
520
+ return SOURCE_MIRROR_EXTENSIONS.has(extname(filePath).toLowerCase());
521
+ }
522
+
523
+ function shouldRewriteImports(filePath: string): boolean {
524
+ return IMPORT_REWRITE_EXTENSIONS.has(extname(filePath).toLowerCase());
525
+ }
526
+
527
+ function rewriteImportSpecifier(
528
+ specifier: string,
529
+ sourceFilePath: string,
530
+ outputFilePath: string,
531
+ docsDir: string,
532
+ mirrorDir: string
533
+ ): string {
534
+ const match = specifier.match(/^([^?#]+)([?#].*)?$/);
535
+ if (!match) return specifier;
536
+ const rawPath = match[1];
537
+ const suffix = match[2] ?? "";
538
+
539
+ let resolvedSourcePath: string | null = null;
540
+ if (rawPath.startsWith("/")) {
541
+ resolvedSourcePath = join(docsDir, rawPath.slice(1));
542
+ } else if (rawPath.startsWith("./") || rawPath.startsWith("../")) {
543
+ resolvedSourcePath = resolve(dirname(sourceFilePath), rawPath);
544
+ }
545
+
546
+ if (!resolvedSourcePath || !isInsideDocsRoot(docsDir, resolvedSourcePath)) {
547
+ return specifier;
548
+ }
549
+
550
+ const relToDocs = relative(docsDir, resolvedSourcePath);
551
+ const mirrorTargetPath = join(mirrorDir, relToDocs);
552
+ const relFromOutput = relative(dirname(outputFilePath), mirrorTargetPath);
553
+ const normalizedRel = toPosixPath(relFromOutput || ".");
554
+ const withDotPrefix = normalizedRel.startsWith(".") ? normalizedRel : `./${normalizedRel}`;
555
+ return `${withDotPrefix}${suffix}`;
556
+ }
557
+
558
+ function rewriteImportsInContent(
559
+ content: string,
560
+ sourceFilePath: string,
561
+ outputFilePath: string,
562
+ docsDir: string,
563
+ mirrorDir: string
564
+ ): string {
565
+ const importFromPattern = /^(\s*import\s+)(.+?)(\s+from\s*["'])([^"']+)(["']\s*;?\s*)$/;
566
+ const exportFromPattern = /^(\s*export\b[^\n]*?\bfrom\s*["'])([^"']+)(["'])/;
567
+ const sideEffectImportPattern = /^(\s*import\s*["'])([^"']+)(["'])/;
568
+ const fencePattern = /^\s*(```+|~~~+)/;
569
+ const mdxOutput = (() => {
570
+ const ext = extname(outputFilePath).toLowerCase();
571
+ return ext === ".md" || ext === ".mdx";
572
+ })();
573
+
574
+ const lines = content.split(/\r?\n/);
575
+ const out: string[] = [];
576
+ let inFence = false;
577
+ let fenceChar = "";
578
+ let injectedMdxHelperImport = false;
579
+
580
+ function importPathFromSpecifier(specifier: string): string {
581
+ const match = specifier.match(/^([^?#]+)/);
582
+ return match ? match[1] : specifier;
583
+ }
584
+
585
+ function isLocalSpecifier(specifier: string): boolean {
586
+ return specifier.startsWith("/") || specifier.startsWith("./") || specifier.startsWith("../");
587
+ }
588
+
589
+ function isMdxSpecifier(specifier: string): boolean {
590
+ const base = importPathFromSpecifier(specifier).toLowerCase();
591
+ return base.endsWith(".mdx") || base.endsWith(".md");
592
+ }
593
+
594
+ function parseDefaultImport(clause: string): { defaultName?: string; namedPart?: string } {
595
+ const trimmed = clause.trim();
596
+ if (!trimmed || trimmed.startsWith("{") || trimmed.startsWith("*")) return {};
597
+
598
+ const commaIdx = trimmed.indexOf(",");
599
+ if (commaIdx === -1) {
600
+ if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(trimmed)) return { defaultName: trimmed };
601
+ return {};
602
+ }
603
+
604
+ const defaultName = trimmed.slice(0, commaIdx).trim();
605
+ const remainder = trimmed.slice(commaIdx + 1).trim();
606
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(defaultName)) return {};
607
+ if (!remainder.startsWith("{") && !remainder.startsWith("*")) return {};
608
+ return { defaultName, namedPart: remainder };
609
+ }
610
+
611
+ for (const line of lines) {
612
+ const fenceMatch = line.match(fencePattern);
613
+ if (fenceMatch) {
614
+ const currentFenceChar = fenceMatch[1][0];
615
+ if (!inFence) {
616
+ inFence = true;
617
+ fenceChar = currentFenceChar;
618
+ } else if (fenceChar === currentFenceChar) {
619
+ inFence = false;
620
+ fenceChar = "";
621
+ }
622
+ out.push(line);
623
+ continue;
624
+ }
625
+
626
+ if (inFence) {
627
+ out.push(line);
628
+ continue;
629
+ }
630
+
631
+ const importMatch = line.match(importFromPattern);
632
+ if (importMatch) {
633
+ const importPrefix = importMatch[1];
634
+ const importClause = importMatch[2];
635
+ const fromPrefix = importMatch[3];
636
+ const specifier = importMatch[4];
637
+ const importSuffix = importMatch[5];
638
+ const rewritten = rewriteImportSpecifier(specifier, sourceFilePath, outputFilePath, docsDir, mirrorDir);
639
+ const { defaultName, namedPart } = parseDefaultImport(importClause);
640
+ const shouldWrapDefaultImport =
641
+ mdxOutput && Boolean(defaultName) && isLocalSpecifier(specifier) && isMdxSpecifier(specifier);
642
+
643
+ if (shouldWrapDefaultImport && defaultName) {
644
+ if (!injectedMdxHelperImport) {
645
+ out.push('import { getMDXComponents as __veluGetMDXComponents } from "@/mdx-components";');
646
+ injectedMdxHelperImport = true;
647
+ }
648
+
649
+ const rawName = `__veluRaw_${defaultName}`;
650
+ const wrappedClause = namedPart ? `${rawName}, ${namedPart}` : rawName;
651
+ out.push(`${importPrefix}${wrappedClause}${fromPrefix}${rewritten}${importSuffix}`);
652
+ out.push(`export const ${defaultName} = (props) => <${rawName} {...props} components={__veluGetMDXComponents()} />;`);
653
+ continue;
654
+ }
655
+
656
+ out.push(`${importPrefix}${importClause}${fromPrefix}${rewritten}${importSuffix}`);
657
+ continue;
658
+ }
659
+
660
+ let nextLine = line.replace(exportFromPattern, (_, prefix: string, specifier: string, suffix: string) => {
661
+ const rewritten = rewriteImportSpecifier(specifier, sourceFilePath, outputFilePath, docsDir, mirrorDir);
662
+ return `${prefix}${rewritten}${suffix}`;
663
+ });
664
+
665
+ nextLine = nextLine.replace(sideEffectImportPattern, (_, prefix: string, specifier: string, suffix: string) => {
666
+ const rewritten = rewriteImportSpecifier(specifier, sourceFilePath, outputFilePath, docsDir, mirrorDir);
667
+ return `${prefix}${rewritten}${suffix}`;
668
+ });
669
+
670
+ out.push(nextLine);
671
+ }
672
+
673
+ return out.join("\n");
674
+ }
675
+
676
+ function copyMirroredSourceFile(srcPath: string, docsDir: string, mirrorDir: string) {
677
+ if (!shouldMirrorSourceFile(srcPath)) return;
678
+ if (!isInsideDocsRoot(docsDir, srcPath)) return;
679
+
680
+ const relPath = relative(docsDir, srcPath);
681
+ const destPath = join(mirrorDir, relPath);
682
+ mkdirSync(dirname(destPath), { recursive: true });
683
+
684
+ if (shouldRewriteImports(srcPath)) {
685
+ const raw = readFileSync(srcPath, "utf-8");
686
+ const rewritten = rewriteImportsInContent(raw, srcPath, destPath, docsDir, mirrorDir);
687
+ writeFileSync(destPath, rewritten, "utf-8");
688
+ return;
689
+ }
690
+
691
+ copyFileSync(srcPath, destPath);
692
+ }
693
+
694
+ function rebuildSourceMirror(docsDir: string, mirrorDir: string) {
695
+ rmSync(mirrorDir, { recursive: true, force: true });
696
+ mkdirSync(mirrorDir, { recursive: true });
697
+
698
+ function walk(dir: string) {
699
+ const entries = readdirSync(dir, { withFileTypes: true });
700
+ for (const entry of entries) {
701
+ if (entry.name.startsWith(".")) continue;
702
+ if (entry.name === "node_modules") continue;
703
+ const srcPath = join(dir, entry.name);
704
+ if (entry.isDirectory()) {
705
+ walk(srcPath);
706
+ continue;
707
+ }
708
+ if (!shouldMirrorSourceFile(srcPath)) continue;
709
+ copyMirroredSourceFile(srcPath, docsDir, mirrorDir);
710
+ }
711
+ }
712
+
713
+ walk(docsDir);
714
+ }
715
+
716
+ function pageLabelFromSlug(slug: string): string {
717
+ const last = slug.split("/").pop()!;
718
+ return last.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
719
+ }
720
+
721
+ function pageBasename(page: string): string {
722
+ return page.split("/").pop()!;
723
+ }
724
+
725
+ interface PageMapping {
726
+ src: string; // original page reference
727
+ dest: string; // destination path under content/docs (without extension)
728
+ kind: "file" | "openapi-operation";
729
+ openapiSpec?: string;
730
+ openapiMethod?: string;
731
+ openapiEndpoint?: string;
732
+ openapiKind?: "path" | "webhook";
733
+ title?: string;
734
+ description?: string;
735
+ deprecated?: boolean;
736
+ version?: string;
737
+ content?: string;
738
+ }
739
+
740
+ interface MetaFile {
741
+ dir: string;
742
+ data: Record<string, unknown>;
743
+ }
744
+
745
+ interface BuildArtifacts {
746
+ pageMap: PageMapping[];
747
+ metaFiles: MetaFile[];
748
+ firstPage: string;
749
+ }
750
+
751
+ function buildArtifacts(config: VeluConfig, docsDirForOpenApi?: string): BuildArtifacts {
752
+ const pageMap: PageMapping[] = [];
753
+ const metaFiles: MetaFile[] = [];
754
+ const rootTabs = (config.navigation.tabs || []).filter((tab) => !tab.href);
755
+ const rootPages = rootTabs.map((tab) => tab.slug);
756
+ const defaultOpenApiSpec = resolveDefaultOpenApiSpec(config.navigation.openapi ?? config.openapi);
757
+ let firstPage = "quickstart";
758
+ let hasFirstPage = false;
759
+ const usedDestinations = new Set<string>();
760
+
761
+ function trackFirstPage(dest: string) {
762
+ if (!hasFirstPage) {
763
+ firstPage = dest;
764
+ hasFirstPage = true;
765
+ }
766
+ }
767
+
768
+ function metaEntry(item: string | VeluSeparator | VeluLink): string {
769
+ if (typeof item === "string") return item;
770
+ if (isSeparator(item)) return `---${item.separator}---`;
771
+ if (isLink(item)) {
772
+ return item.icon
773
+ ? `[${item.icon}][${item.label}](${item.href})`
774
+ : `[${item.label}](${item.href})`;
775
+ }
776
+ return String(item);
777
+ }
778
+
779
+ function uniqueDestination(dest: string): string {
780
+ if (!usedDestinations.has(dest)) {
781
+ usedDestinations.add(dest);
782
+ return dest;
783
+ }
784
+ let count = 2;
785
+ while (usedDestinations.has(`${dest}-${count}`)) count += 1;
786
+ const candidate = `${dest}-${count}`;
787
+ usedDestinations.add(candidate);
788
+ return candidate;
149
789
  }
150
790
 
151
- return { pageMap, metaFiles, firstPage };
152
- }
791
+ function metaEntryForDestination(baseDir: string, destination: string): string {
792
+ const fromParts = baseDir.split("/").filter(Boolean);
793
+ const toParts = destination.split("/").filter(Boolean);
153
794
 
154
- // ── Build ──────────────────────────────────────────────────────────────────────
795
+ let index = 0;
796
+ while (index < fromParts.length && index < toParts.length && fromParts[index] === toParts[index]) {
797
+ index += 1;
798
+ }
155
799
 
156
- function build(docsDir: string, outDir: string) {
157
- console.log(`📖 Loading velu.json from: ${docsDir}`);
158
- const config = loadConfig(docsDir);
800
+ const up = Array(fromParts.length - index).fill("..");
801
+ const down = toParts.slice(index);
802
+ const rel = [...up, ...down].join("/");
803
+ return rel || pageBasename(destination);
804
+ }
159
805
 
160
- if (existsSync(outDir)) {
161
- rmSync(outDir, { recursive: true, force: true });
806
+ function resolveGenerationDestination(openapi: VeluOpenApiSource | undefined, fallback: string): string {
807
+ const override = resolveOpenApiDirectory(openapi);
808
+ if (!override) return fallback;
809
+ if (!fallback) return override;
810
+ if (override === fallback || override.startsWith(`${fallback}/`)) return override;
811
+ return `${fallback}/${override}`;
812
+ }
813
+
814
+ function toPageMapping(item: string, destDir: string, inheritedSpec?: string): PageMapping {
815
+ const parsedOpenApi = parseOpenApiOperationRef(item, inheritedSpec);
816
+ if (!parsedOpenApi) {
817
+ const basename = pageBasename(item);
818
+ const dest = uniqueDestination(`${destDir}/${basename}`);
819
+ return { src: item, dest, kind: "file" };
820
+ }
821
+
822
+ const slug = slugFromOpenApiOperation(parsedOpenApi.method, parsedOpenApi.endpoint);
823
+ const dest = uniqueDestination(`${destDir}/${slug}`);
824
+ return {
825
+ src: item,
826
+ dest,
827
+ kind: "openapi-operation",
828
+ openapiSpec: parsedOpenApi.spec,
829
+ openapiMethod: parsedOpenApi.method,
830
+ openapiEndpoint: parsedOpenApi.endpoint,
831
+ openapiKind: parsedOpenApi.kind,
832
+ title: parsedOpenApi.title,
833
+ description: parsedOpenApi.description,
834
+ deprecated: parsedOpenApi.deprecated,
835
+ version: parsedOpenApi.version,
836
+ content: parsedOpenApi.content,
837
+ };
162
838
  }
163
839
 
164
- // ── 1. Copy engine static files ──────────────────────────────────────────
165
- cpSync(ENGINE_DIR, outDir, { recursive: true });
166
- // Remove legacy Astro template leftovers if present in the packaged engine.
167
- rmSync(join(outDir, "src"), { recursive: true, force: true });
168
- console.log("📦 Copied engine files");
840
+ function resolveInheritedVersion(value: unknown, inherited?: string): string | undefined {
841
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
842
+ return inherited;
843
+ }
169
844
 
170
- // ── 2. Create additional directories ─────────────────────────────────────
171
- mkdirSync(join(outDir, "content", "docs"), { recursive: true });
172
- mkdirSync(join(outDir, "public"), { recursive: true });
845
+ function toPageMappingWithVersion(
846
+ item: string,
847
+ destDir: string,
848
+ inheritedSpec?: string,
849
+ inheritedVersion?: string,
850
+ ): PageMapping {
851
+ const mapping = toPageMapping(item, destDir, inheritedSpec);
852
+ if (mapping.kind === "openapi-operation" && mapping.version === undefined) {
853
+ mapping.version = inheritedVersion;
854
+ }
855
+ return mapping;
856
+ }
173
857
 
174
- // ── 3. Copy velu.json into the generated project ─────────────────────────
175
- copyFileSync(join(docsDir, "velu.json"), join(outDir, "velu.json"));
176
- console.log("📋 Copied velu.json");
858
+ function toOperationMapping(
859
+ ref: ParsedOpenApiOperationRef,
860
+ destDir: string,
861
+ inheritedVersion?: string,
862
+ ): PageMapping {
863
+ const slug = slugFromOpenApiOperation(ref.method, ref.endpoint);
864
+ const dest = uniqueDestination(`${destDir}/${slug}`);
865
+ return {
866
+ src: `${ref.spec ? `${ref.spec} ` : ""}${ref.method} ${ref.endpoint}`,
867
+ dest,
868
+ kind: "openapi-operation",
869
+ openapiSpec: ref.spec,
870
+ openapiMethod: ref.method,
871
+ openapiEndpoint: ref.endpoint,
872
+ openapiKind: ref.kind,
873
+ title: ref.title,
874
+ description: ref.description,
875
+ deprecated: ref.deprecated,
876
+ version: ref.version ?? inheritedVersion,
877
+ content: ref.content,
878
+ };
879
+ }
177
880
 
178
- // ── 4. Build content + metadata artifacts ────────────────────────────────
179
- const { pageMap, metaFiles, firstPage } = buildArtifacts(config);
881
+ function buildOpenApiMappings(
882
+ openapi: VeluOpenApiSource | undefined,
883
+ destDir: string,
884
+ fallbackSpec?: string,
885
+ inheritedVersion?: string,
886
+ ): PageMapping[] {
887
+ if (!docsDirForOpenApi) return [];
888
+ const specs = resolveOpenApiSpecList(openapi);
889
+ if (specs.length === 0 && fallbackSpec) specs.push(fallbackSpec);
890
+ if (specs.length === 0) return [];
180
891
 
181
- // 4a) Write folder meta.json files (tabs/groups ordering & labels)
182
- for (const meta of metaFiles) {
183
- const metaPath = join(outDir, "content", "docs", meta.dir, "meta.json");
184
- mkdirSync(dirname(metaPath), { recursive: true });
185
- writeFileSync(metaPath, JSON.stringify(meta.data, null, 2) + "\n", "utf-8");
892
+ const output: PageMapping[] = [];
893
+ const seen = new Set<string>();
894
+ for (const spec of specs) {
895
+ for (const operation of loadOpenApiOperations(spec, docsDirForOpenApi)) {
896
+ const key = `${operation.spec ?? ""}::${operation.kind ?? "path"}::${operation.method}::${operation.endpoint}`;
897
+ if (seen.has(key)) continue;
898
+ seen.add(key);
899
+ output.push(toOperationMapping(operation, destDir, inheritedVersion));
900
+ }
901
+ }
902
+ return output;
186
903
  }
187
904
 
188
- // 4b) Copy all referenced .md files (slug-based destinations)
189
- for (const { src, dest } of pageMap) {
190
- const srcPath = join(docsDir, `${src}.md`);
191
- const destPath = join(outDir, "content", "docs", `${dest}.mdx`);
905
+ function addGroup(
906
+ group: VeluGroup,
907
+ parentDir: string,
908
+ inheritedOpenApiSpec?: string,
909
+ inheritedVersion?: string,
910
+ ) {
911
+ const groupDir = `${parentDir}/${group.slug}`;
912
+ const pages: string[] = [];
913
+ const openApiSpec = resolveDefaultOpenApiSpec(group.openapi) ?? inheritedOpenApiSpec;
914
+ const groupVersion = resolveInheritedVersion(group.version, inheritedVersion);
192
915
 
193
- if (!existsSync(srcPath)) {
194
- console.warn(`⚠️ Missing: ${srcPath}`);
195
- continue;
916
+ const groupPageItems = Array.isArray(group.pages) ? group.pages : [];
917
+ for (const item of groupPageItems) {
918
+ if (typeof item === "string") {
919
+ const mapping = toPageMappingWithVersion(item, groupDir, openApiSpec, groupVersion);
920
+ pageMap.push(mapping);
921
+ pages.push(metaEntryForDestination(groupDir, mapping.dest));
922
+ trackFirstPage(mapping.dest);
923
+ } else if (isGroup(item)) {
924
+ addGroup(item, groupDir, openApiSpec, groupVersion);
925
+ pages.push(item.hidden ? `!${item.slug}` : item.slug);
926
+ } else if (isSeparator(item)) {
927
+ pages.push(`---${item.separator}---`);
928
+ } else if (isLink(item)) {
929
+ pages.push(
930
+ item.icon
931
+ ? `[${item.icon}][${item.label}](${item.href})`
932
+ : `[${item.label}](${item.href})`
933
+ );
934
+ }
196
935
  }
197
936
 
198
- mkdirSync(dirname(destPath), { recursive: true });
937
+ if (groupPageItems.length === 0 && group.openapi !== undefined) {
938
+ const generatedDestDir = resolveGenerationDestination(group.openapi, groupDir);
939
+ const generatedMappings = buildOpenApiMappings(group.openapi, generatedDestDir, openApiSpec, groupVersion);
940
+ for (const mapping of generatedMappings) {
941
+ pageMap.push(mapping);
942
+ pages.push(metaEntryForDestination(groupDir, mapping.dest));
943
+ trackFirstPage(mapping.dest);
944
+ }
945
+ }
946
+
947
+ const groupMeta: Record<string, unknown> = {
948
+ title: group.group,
949
+ pages,
950
+ defaultOpen: group.expanded !== false,
951
+ };
952
+
953
+ if (group.icon) groupMeta.icon = group.icon;
954
+ if (group.iconType) groupMeta.iconType = group.iconType;
955
+ if (group.description) groupMeta.description = group.description;
956
+
957
+ metaFiles.push({ dir: groupDir, data: groupMeta });
958
+ }
959
+
960
+ for (const tab of rootTabs) {
961
+ const tabPages: string[] = [];
962
+ const tabOpenApiSpec = resolveDefaultOpenApiSpec(tab.openapi) ?? defaultOpenApiSpec;
963
+ const tabVersion = resolveInheritedVersion(tab.version);
199
964
 
200
- let content = readFileSync(srcPath, "utf-8");
201
- if (!content.startsWith("---")) {
202
- const titleMatch = content.match(/^#\s+(.+)$/m);
203
- const title = titleMatch ? titleMatch[1] : pageLabelFromSlug(src);
204
- if (titleMatch) {
205
- content = content.replace(/^#\s+.+$/m, "").trimStart();
965
+ if (tab.groups) {
966
+ for (const group of tab.groups) {
967
+ addGroup(group, tab.slug, tabOpenApiSpec, tabVersion);
968
+ tabPages.push(group.hidden ? `!${group.slug}` : group.slug);
969
+ }
970
+ }
971
+
972
+ const tabPageItems = Array.isArray(tab.pages) ? tab.pages : [];
973
+ if (tabPageItems.length > 0) {
974
+ for (const item of tabPageItems) {
975
+ if (typeof item === "string") {
976
+ const mapping = toPageMappingWithVersion(item, tab.slug, tabOpenApiSpec, tabVersion);
977
+ pageMap.push(mapping);
978
+ tabPages.push(metaEntryForDestination(tab.slug, mapping.dest));
979
+ trackFirstPage(mapping.dest);
980
+ } else {
981
+ tabPages.push(metaEntry(item));
982
+ }
206
983
  }
207
- content = `---\ntitle: "${title}"\n---\n\n${content}`;
208
984
  }
209
985
 
210
- writeFileSync(destPath, content, "utf-8");
211
- }
212
- console.log(`📄 Generated ${pageMap.length} pages + ${metaFiles.length} navigation meta files`);
213
-
214
- // ── 5. Generate theme CSS (dynamic — depends on user config) ─────────────
215
- const themeCss = generateThemeCss({
216
- theme: config.theme,
217
- colors: config.colors,
218
- appearance: config.appearance,
219
- styling: config.styling,
220
- });
221
- writeFileSync(join(outDir, "app", "velu-theme.css"), themeCss, "utf-8");
222
- console.log(`🎨 Generated theme: ${resolveThemeName(config.theme)}`);
223
-
224
- // ── 6. Generate index.mdx (dynamic — references first page) ──────────────
225
- writeFileSync(
226
- join(outDir, "content", "docs", "index.mdx"),
227
- `---\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`,
228
- "utf-8"
229
- );
230
-
231
- // ── 7. Generate minimal package.json (type: module, no local deps) ───────
232
- const sitePkg = {
233
- name: "velu-docs-site",
234
- version: "0.0.1",
235
- private: true,
236
- type: "module",
237
- };
238
- writeFileSync(join(outDir, "package.json"), JSON.stringify(sitePkg, null, 2) + "\n", "utf-8");
986
+ if ((tab.groups?.length ?? 0) === 0 && tabPageItems.length === 0 && tab.openapi !== undefined) {
987
+ const generatedDestDir = resolveGenerationDestination(tab.openapi, tab.slug);
988
+ const generatedMappings = buildOpenApiMappings(tab.openapi, generatedDestDir, tabOpenApiSpec, tabVersion);
989
+ for (const mapping of generatedMappings) {
990
+ pageMap.push(mapping);
991
+ tabPages.push(metaEntryForDestination(tab.slug, mapping.dest));
992
+ trackFirstPage(mapping.dest);
993
+ }
994
+ }
995
+
996
+ const tabMeta: Record<string, unknown> = {
997
+ title: tab.tab,
998
+ root: true,
999
+ pages: tabPages,
1000
+ };
1001
+
1002
+ if (tab.icon) tabMeta.icon = tab.icon;
1003
+ if (tab.iconType) tabMeta.iconType = tab.iconType;
1004
+
1005
+ metaFiles.push({ dir: tab.slug, data: tabMeta });
1006
+ }
1007
+
1008
+ if (rootPages.length > 0) {
1009
+ metaFiles.push({ dir: "", data: { pages: rootPages } });
1010
+ }
1011
+
1012
+ return { pageMap, metaFiles, firstPage };
1013
+ }
1014
+
1015
+ // ── Build ──────────────────────────────────────────────────────────────────────
1016
+
1017
+ function build(docsDir: string, outDir: string) {
1018
+ const configPath = resolveConfigPath(docsDir);
1019
+ const configName = configPath.endsWith(PRIMARY_CONFIG_NAME) ? PRIMARY_CONFIG_NAME : LEGACY_CONFIG_NAME;
1020
+ console.log(`📖 Loading ${configName} from: ${docsDir}`);
1021
+ const config = loadConfig(docsDir);
1022
+
1023
+ if (existsSync(outDir)) {
1024
+ rmSync(outDir, { recursive: true, force: true });
1025
+ }
1026
+
1027
+ // ── 1. Copy engine static files ──────────────────────────────────────────
1028
+ cpSync(ENGINE_DIR, outDir, { recursive: true });
1029
+ // Remove legacy Astro template leftovers if present in the packaged engine.
1030
+ rmSync(join(outDir, "src"), { recursive: true, force: true });
1031
+ console.log("📦 Copied engine files");
1032
+
1033
+ // ── 2. Create additional directories ─────────────────────────────────────
1034
+ mkdirSync(join(outDir, "content", "docs"), { recursive: true });
1035
+ mkdirSync(join(outDir, "public"), { recursive: true });
1036
+ const sourceMirrorDir = join(outDir, SOURCE_MIRROR_DIR);
1037
+ rebuildSourceMirror(docsDir, sourceMirrorDir);
1038
+
1039
+ // ── 3. Copy config into the generated project ────────────────────────────
1040
+ copyFileSync(configPath, join(outDir, PRIMARY_CONFIG_NAME));
1041
+ copyFileSync(configPath, join(outDir, LEGACY_CONFIG_NAME));
1042
+ console.log(`📋 Copied ${configName} as ${PRIMARY_CONFIG_NAME} (and legacy ${LEGACY_CONFIG_NAME})`);
1043
+
1044
+ // ── 3b. Copy static assets from docs project into public/ ─────────────────
1045
+ copyStaticAssets(docsDir, join(outDir, "public"));
1046
+ writeRedirectArtifacts(config, outDir);
1047
+ if ((config.redirects ?? []).length > 0) {
1048
+ console.log("↪️ Generated redirect artifacts");
1049
+ }
1050
+ console.log("🖼️ Copied static assets");
1051
+
1052
+ // ── 4. Build content + metadata artifacts ────────────────────────────────
1053
+ const contentDir = join(outDir, "content", "docs");
1054
+ const navLanguages = config.navigation.languages;
1055
+ const simpleLanguages = config.languages || [];
1056
+
1057
+ function processPage(srcPath: string, destPath: string, slug: string) {
1058
+ mkdirSync(dirname(destPath), { recursive: true });
1059
+ let content = readFileSync(srcPath, "utf-8");
1060
+ if (!content.startsWith("---")) {
1061
+ const titleMatch = content.match(/^#\s+(.+)$/m);
1062
+ const title = titleMatch ? titleMatch[1] : pageLabelFromSlug(slug);
1063
+ if (titleMatch) {
1064
+ content = content.replace(/^#\s+.+$/m, "").trimStart();
1065
+ }
1066
+ content = `---\ntitle: "${title}"\n---\n\n${content}`;
1067
+ }
1068
+ content = rewriteImportsInContent(content, srcPath, destPath, docsDir, sourceMirrorDir);
1069
+ writeFileSync(destPath, content, "utf-8");
1070
+ }
1071
+
1072
+ function writeLangContent(
1073
+ langCode: string,
1074
+ artifacts: BuildArtifacts,
1075
+ isDefault: boolean,
1076
+ useLangFolders = false
1077
+ ) {
1078
+ const storagePrefix = useLangFolders ? langCode : (isDefault ? "" : langCode);
1079
+ const urlPrefix = isDefault ? "" : langCode;
1080
+
1081
+ // Write meta files
1082
+ const metas = storagePrefix
1083
+ ? artifacts.metaFiles.map((m) => ({ dir: m.dir ? `${storagePrefix}/${m.dir}` : storagePrefix, data: { ...m.data } }))
1084
+ : artifacts.metaFiles;
1085
+ for (const meta of metas) {
1086
+ const metaPath = join(contentDir, meta.dir, "meta.json");
1087
+ mkdirSync(dirname(metaPath), { recursive: true });
1088
+ writeFileSync(metaPath, JSON.stringify(meta.data, null, 2) + "\n", "utf-8");
1089
+ }
239
1090
 
240
- console.log("📦 Generated boilerplate");
241
- console.log(`\n Site generated at: ${outDir}`);
242
- }
1091
+ function sanitizeFrontmatterValue(value: string): string {
1092
+ return value.replace(/\r?\n+/g, " ").replace(/"/g, '\\"').trim();
1093
+ }
243
1094
 
244
- export { build };
1095
+ // Copy pages using explicit source paths from docs.json/velu.json
1096
+ for (const mapping of artifacts.pageMap) {
1097
+ const destPath = join(
1098
+ contentDir,
1099
+ storagePrefix ? `${storagePrefix}/${mapping.dest}.mdx` : `${mapping.dest}.mdx`,
1100
+ );
1101
+
1102
+ if (mapping.kind === "openapi-operation") {
1103
+ mkdirSync(dirname(destPath), { recursive: true });
1104
+ const operationLabel = `${mapping.openapiMethod ?? "GET"} ${mapping.openapiEndpoint ?? "/"}`;
1105
+ const normalizedSpec = normalizeOpenApiSpecForFrontmatter(mapping.openapiSpec);
1106
+ const openapiValue = normalizedSpec
1107
+ ? `${normalizedSpec} ${operationLabel}`
1108
+ : operationLabel;
1109
+ const title = sanitizeFrontmatterValue(mapping.title ?? operationLabel);
1110
+ const description = typeof mapping.description === "string"
1111
+ ? sanitizeFrontmatterValue(mapping.description)
1112
+ : "";
1113
+ const version = typeof mapping.version === "string"
1114
+ ? sanitizeFrontmatterValue(mapping.version)
1115
+ : "";
1116
+ const openapi = openapiValue.replace(/"/g, '\\"');
1117
+ const warning = normalizedSpec
1118
+ ? ""
1119
+ : "\n> Warning: No OpenAPI spec source was resolved for this operation. Set `openapi` on this tab/group/navigation or at the top level.\n";
1120
+ const descriptionLine = description ? `\ndescription: "${description}"` : "";
1121
+ const deprecatedLine = mapping.deprecated === true ? `\ndeprecated: true` : "";
1122
+ const statusLine = mapping.deprecated === true ? `\nstatus: "deprecated"` : "";
1123
+ const versionLine = version ? `\nversion: "${version}"` : "";
1124
+ const content = typeof mapping.content === "string" ? `${mapping.content.trim()}\n` : "";
1125
+ writeFileSync(
1126
+ destPath,
1127
+ `---\ntitle: "${title}"${descriptionLine}${deprecatedLine}${statusLine}${versionLine}\nopenapi: "${openapi}"\n---\n${warning}${content}`,
1128
+ "utf-8",
1129
+ );
1130
+ continue;
1131
+ }
1132
+
1133
+ const src = mapping.src;
1134
+ // Check for .mdx first, then .md
1135
+ let srcPath = join(docsDir, `${src}.mdx`);
1136
+ let ext = ".mdx";
1137
+ if (!existsSync(srcPath)) {
1138
+ srcPath = join(docsDir, `${src}.md`);
1139
+ ext = ".md";
1140
+ }
1141
+ if (!existsSync(srcPath)) {
1142
+ console.warn(`Warning: Missing page source: ${src}${ext} (language: ${langCode})`);
1143
+ continue;
1144
+ }
1145
+ processPage(srcPath, destPath, src);
1146
+ }
1147
+
1148
+ // Index page
1149
+ const href = urlPrefix ? `/${urlPrefix}/${artifacts.firstPage}/` : `/${artifacts.firstPage}/`;
1150
+ const indexPath = storagePrefix ? join(contentDir, storagePrefix, "index.mdx") : join(contentDir, "index.mdx");
1151
+ writeFileSync(
1152
+ indexPath,
1153
+ `---\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`,
1154
+ "utf-8"
1155
+ );
1156
+ }
1157
+
1158
+ let totalPages = 0;
1159
+ let totalMeta = 0;
1160
+
1161
+ if (navLanguages && navLanguages.length > 0) {
1162
+ // ── Mode 1: Per-language navigation (Mintlify-style) ──────────────
1163
+ const rootPages: string[] = [];
1164
+
1165
+ for (let i = 0; i < navLanguages.length; i++) {
1166
+ const langEntry = navLanguages[i];
1167
+ const isDefault = i === 0;
1168
+ const langConfig = { ...config, navigation: { ...config.navigation, tabs: langEntry.tabs } } as VeluConfig;
1169
+ const artifacts = buildArtifacts(langConfig, docsDir);
1170
+ writeLangContent(langEntry.language, artifacts, isDefault, true);
1171
+ totalPages += artifacts.pageMap.length;
1172
+ totalMeta += artifacts.metaFiles.length;
1173
+ rootPages.push(`!${langEntry.language}`);
1174
+ }
1175
+
1176
+ const rootMetaPath = join(contentDir, "meta.json");
1177
+ writeFileSync(rootMetaPath, JSON.stringify({ pages: rootPages }, null, 2) + "\n", "utf-8");
1178
+ } else {
1179
+ // ── Mode 2: Simple (single-lang or same-nav multi-lang) ───────────
1180
+ const artifacts = buildArtifacts(config, docsDir);
1181
+ const useLangFolders = simpleLanguages.length > 1;
1182
+ writeLangContent(simpleLanguages[0] || "en", artifacts, true, useLangFolders);
1183
+ totalPages += artifacts.pageMap.length;
1184
+ totalMeta += artifacts.metaFiles.length;
1185
+
1186
+ if (simpleLanguages.length > 1) {
1187
+ const rootMetaPath = join(contentDir, "meta.json");
1188
+ const rootPages = [`!${simpleLanguages[0] || "en"}`];
1189
+ for (const lang of simpleLanguages.slice(1)) {
1190
+ writeLangContent(lang, artifacts, false, true);
1191
+ rootPages.push(`!${lang}`);
1192
+ totalPages += artifacts.pageMap.length;
1193
+ totalMeta += artifacts.metaFiles.length;
1194
+ }
1195
+ writeFileSync(rootMetaPath, JSON.stringify({ pages: rootPages }, null, 2) + "\n", "utf-8");
1196
+ }
1197
+ }
1198
+
1199
+ console.log(`📄 Generated ${totalPages} pages + ${totalMeta} navigation meta files`);
1200
+
1201
+ // ── 5. Generate theme CSS (dynamic — depends on user config) ─────────────
1202
+ const themeCss = generateThemeCss({
1203
+ theme: config.theme,
1204
+ colors: config.colors,
1205
+ appearance: config.appearance,
1206
+ styling: config.styling,
1207
+ });
1208
+ writeFileSync(join(outDir, "app", "velu-theme.css"), themeCss, "utf-8");
1209
+ console.log(`🎨 Generated theme: ${resolveThemeName(config.theme)}`);
1210
+
1211
+
1212
+ // ── 7. Generate minimal package.json (type: module, no local deps) ───────
1213
+ const sitePkg = {
1214
+ name: "velu-docs-site",
1215
+ version: "0.0.1",
1216
+ private: true,
1217
+ type: "module",
1218
+ };
1219
+ writeFileSync(join(outDir, "package.json"), JSON.stringify(sitePkg, null, 2) + "\n", "utf-8");
1220
+
1221
+ console.log("📦 Generated boilerplate");
1222
+ console.log(`\n✅ Site generated at: ${outDir}`);
1223
+ }
1224
+
1225
+ export { build };