@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
package/src/build.ts CHANGED
@@ -1,408 +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
  import { normalizeConfigNavigation } from "./navigation-normalize.js";
6
-
7
- // ── Engine directory (shipped with the CLI package) ──────────────────────────
8
- const __filename = fileURLToPath(import.meta.url);
9
- const __dirname = dirname(__filename);
10
- const PACKAGED_ENGINE_DIR = join(__dirname, "engine");
11
- const DEV_ENGINE_DIR = join(__dirname, "..", "src", "engine");
12
- const ENGINE_DIR = existsSync(DEV_ENGINE_DIR) ? DEV_ENGINE_DIR : PACKAGED_ENGINE_DIR;
13
-
14
- // ── Types (used only by build.ts for page copying) ─────────────────────────────
15
-
16
- interface VeluSeparator {
17
- separator: string;
18
- }
19
-
20
- interface VeluLink {
21
- href: string;
22
- label: string;
23
- icon?: string;
24
- }
25
-
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
+
26
50
  interface VeluAnchor {
27
51
  anchor: string;
28
52
  href?: string;
29
53
  icon?: string;
54
+ iconType?: string;
55
+ openapi?: VeluOpenApiSource;
56
+ version?: string;
30
57
  color?: {
31
58
  light: string;
32
59
  dark: string;
33
60
  };
34
- tabs?: VeluTab[];
35
- hidden?: boolean;
36
- }
37
-
38
- interface VeluGlobalTab {
39
- tab: string;
40
- href: string;
41
- icon?: string;
42
- }
43
-
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
+
44
72
  interface VeluGroup {
45
73
  group: string;
46
74
  slug: string;
47
75
  icon?: string;
76
+ iconType?: string;
77
+ version?: string;
78
+ openapi?: VeluOpenApiSource;
48
79
  expanded?: boolean;
49
80
  description?: string;
50
81
  hidden?: boolean;
51
- pages: (string | VeluGroup | VeluSeparator | VeluLink)[];
52
- }
53
-
54
- interface VeluMenuItem {
55
- item: string;
56
- icon?: string;
57
- groups?: VeluGroup[];
58
- pages?: (string | VeluSeparator | VeluLink)[];
59
- }
60
-
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
+
61
94
  interface VeluTab {
62
95
  tab: string;
63
96
  slug: string;
64
97
  icon?: string;
98
+ iconType?: string;
65
99
  href?: string;
100
+ openapi?: VeluOpenApiSource;
101
+ version?: string;
66
102
  pages?: (string | VeluSeparator | VeluLink)[];
67
103
  groups?: VeluGroup[];
68
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;
69
160
  }
70
161
 
71
- interface VeluLanguageNav {
72
- language: string;
73
- tabs: VeluTab[];
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;
74
191
  }
75
192
 
76
- interface VeluProductNav {
77
- product: string;
78
- icon?: string;
79
- tabs?: VeluTab[];
80
- pages?: (string | VeluSeparator | VeluLink)[];
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;
81
204
  }
82
205
 
83
- interface VeluVersionNav {
84
- version: string;
85
- tabs: VeluTab[];
86
- }
206
+ function parseOpenApiOperationRef(value: string, inheritedSpec?: string): ParsedOpenApiOperationRef | null {
207
+ const trimmed = value.trim();
208
+ if (!trimmed) return null;
87
209
 
88
- interface VeluConfig {
89
- $schema?: string;
90
- theme?: string;
91
- colors?: VeluColors;
92
- appearance?: "system" | "light" | "dark";
93
- styling?: VeluStyling;
94
- languages?: string[];
95
- navigation: {
96
- tabs?: VeluTab[];
97
- languages?: VeluLanguageNav[];
98
- products?: VeluProductNav[];
99
- versions?: VeluVersionNav[];
100
- anchors?: VeluAnchor[];
101
- global?: {
102
- anchors?: VeluAnchor[];
103
- tabs?: VeluGlobalTab[];
104
- };
105
- };
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
+ }
222
+
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}`;
106
247
  }
107
248
 
108
- function isSeparator(item: unknown): item is VeluSeparator {
109
- return typeof item === "object" && item !== null && "separator" in item;
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 [];
110
262
  }
111
263
 
112
- function isLink(item: unknown): item is VeluLink {
113
- return typeof item === "object" && item !== null && "href" in item && "label" in item;
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;
114
271
  }
115
272
 
116
- function isGroup(item: unknown): item is VeluGroup {
117
- return typeof item === "object" && item !== null && "group" in item;
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;
118
279
  }
119
280
 
120
- // ── Helpers ────────────────────────────────────────────────────────────────────
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;
295
+ }
296
+ return null;
297
+ }
121
298
 
122
- function loadConfig(docsDir: string): VeluConfig {
123
- const raw = readFileSync(join(docsDir, "velu.json"), "utf-8");
124
- const parsed = JSON.parse(raw) as VeluConfig;
125
- return normalizeConfigNavigation(parsed);
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
+ };
126
312
  }
127
313
 
128
- function pageLabelFromSlug(slug: string): string {
129
- const last = slug.split("/").pop()!;
130
- return last.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
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;
131
320
  }
132
321
 
133
- function pageBasename(page: string): string {
134
- return page.split("/").pop()!;
322
+ function loadOpenApiOperations(specSource: string, docsDir: string): ParsedOpenApiOperationRef[] {
323
+ if (/^https?:\/\//i.test(specSource) || specSource.startsWith("file://")) return [];
324
+
325
+ const resolvedPath = specSource.startsWith("/")
326
+ ? join(docsDir, specSource.replace(/^\/+/, ""))
327
+ : resolve(docsDir, specSource);
328
+ if (!existsSync(resolvedPath)) return [];
329
+
330
+ const parsed = parseOpenApiDocument(readFileSync(resolvedPath, "utf-8"));
331
+ if (!parsed) return [];
332
+
333
+ const paths = parsed.paths;
334
+ const webhooks = parsed.webhooks;
335
+
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
+ });
358
+ }
359
+ }
360
+ }
361
+
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
+ });
382
+ }
383
+ }
384
+ return output;
135
385
  }
136
386
 
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
+
137
725
  interface PageMapping {
138
- src: string; // original page reference (file path without .md)
726
+ src: string; // original page reference
139
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;
140
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;
789
+ }
141
790
 
142
- interface MetaFile {
143
- dir: string;
144
- data: Record<string, unknown>;
145
- }
791
+ function metaEntryForDestination(baseDir: string, destination: string): string {
792
+ const fromParts = baseDir.split("/").filter(Boolean);
793
+ const toParts = destination.split("/").filter(Boolean);
146
794
 
147
- interface BuildArtifacts {
148
- pageMap: PageMapping[];
149
- metaFiles: MetaFile[];
150
- firstPage: string;
151
- }
795
+ let index = 0;
796
+ while (index < fromParts.length && index < toParts.length && fromParts[index] === toParts[index]) {
797
+ index += 1;
798
+ }
152
799
 
153
- function buildArtifacts(config: VeluConfig): BuildArtifacts {
154
- const pageMap: PageMapping[] = [];
155
- const metaFiles: MetaFile[] = [];
156
- const rootTabs = (config.navigation.tabs || []).filter((tab) => !tab.href);
157
- const rootPages = rootTabs.map((tab) => tab.slug);
158
- let firstPage = "quickstart";
159
- let hasFirstPage = false;
160
-
161
- function trackFirstPage(dest: string) {
162
- if (!hasFirstPage) {
163
- firstPage = dest;
164
- hasFirstPage = true;
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
+ }
805
+
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" };
165
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
+ };
166
838
  }
167
839
 
168
- function metaEntry(item: string | VeluSeparator | VeluLink): string {
169
- if (typeof item === "string") return item;
170
- if (isSeparator(item)) return `---${item.separator}---`;
171
- if (isLink(item)) {
172
- return item.icon
173
- ? `[${item.icon}][${item.label}](${item.href})`
174
- : `[${item.label}](${item.href})`;
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
+ }
844
+
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;
175
854
  }
176
- return String(item);
855
+ return mapping;
177
856
  }
178
857
 
179
- function addGroup(group: VeluGroup, parentDir: string) {
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
+ }
880
+
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 [];
891
+
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;
903
+ }
904
+
905
+ function addGroup(
906
+ group: VeluGroup,
907
+ parentDir: string,
908
+ inheritedOpenApiSpec?: string,
909
+ inheritedVersion?: string,
910
+ ) {
180
911
  const groupDir = `${parentDir}/${group.slug}`;
181
912
  const pages: string[] = [];
913
+ const openApiSpec = resolveDefaultOpenApiSpec(group.openapi) ?? inheritedOpenApiSpec;
914
+ const groupVersion = resolveInheritedVersion(group.version, inheritedVersion);
182
915
 
183
- for (const item of group.pages) {
916
+ const groupPageItems = Array.isArray(group.pages) ? group.pages : [];
917
+ for (const item of groupPageItems) {
184
918
  if (typeof item === "string") {
185
- const basename = pageBasename(item);
186
- const dest = `${groupDir}/${basename}`;
187
- pageMap.push({ src: item, dest });
188
- pages.push(basename);
189
- trackFirstPage(dest);
919
+ const mapping = toPageMappingWithVersion(item, groupDir, openApiSpec, groupVersion);
920
+ pageMap.push(mapping);
921
+ pages.push(metaEntryForDestination(groupDir, mapping.dest));
922
+ trackFirstPage(mapping.dest);
190
923
  } else if (isGroup(item)) {
191
- addGroup(item, groupDir);
924
+ addGroup(item, groupDir, openApiSpec, groupVersion);
192
925
  pages.push(item.hidden ? `!${item.slug}` : item.slug);
193
926
  } else if (isSeparator(item)) {
194
927
  pages.push(`---${item.separator}---`);
195
928
  } else if (isLink(item)) {
196
- pages.push(
197
- item.icon
198
- ? `[${item.icon}][${item.label}](${item.href})`
199
- : `[${item.label}](${item.href})`
200
- );
929
+ pages.push(
930
+ item.icon
931
+ ? `[${item.icon}][${item.label}](${item.href})`
932
+ : `[${item.label}](${item.href})`
933
+ );
201
934
  }
202
935
  }
203
936
 
204
- const groupMeta: Record<string, unknown> = {
205
- title: group.group,
206
- pages,
207
- defaultOpen: group.expanded !== false,
208
- };
209
-
210
- if (group.icon) groupMeta.icon = group.icon;
211
- if (group.description) groupMeta.description = group.description;
212
-
213
- metaFiles.push({ dir: groupDir, data: groupMeta });
214
- }
215
-
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
+
216
960
  for (const tab of rootTabs) {
217
961
  const tabPages: string[] = [];
962
+ const tabOpenApiSpec = resolveDefaultOpenApiSpec(tab.openapi) ?? defaultOpenApiSpec;
963
+ const tabVersion = resolveInheritedVersion(tab.version);
218
964
 
219
965
  if (tab.groups) {
220
966
  for (const group of tab.groups) {
221
- addGroup(group, tab.slug);
967
+ addGroup(group, tab.slug, tabOpenApiSpec, tabVersion);
222
968
  tabPages.push(group.hidden ? `!${group.slug}` : group.slug);
223
969
  }
224
970
  }
225
-
226
- if (tab.pages) {
227
- for (const item of tab.pages) {
971
+
972
+ const tabPageItems = Array.isArray(tab.pages) ? tab.pages : [];
973
+ if (tabPageItems.length > 0) {
974
+ for (const item of tabPageItems) {
228
975
  if (typeof item === "string") {
229
- const basename = pageBasename(item);
230
- const dest = `${tab.slug}/${basename}`;
231
- pageMap.push({ src: item, dest });
232
- tabPages.push(basename);
233
- trackFirstPage(dest);
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);
234
980
  } else {
235
981
  tabPages.push(metaEntry(item));
236
982
  }
237
983
  }
238
984
  }
239
985
 
240
- const tabMeta: Record<string, unknown> = {
241
- title: tab.tab,
242
- root: true,
243
- pages: tabPages,
244
- };
245
-
246
- if (tab.icon) tabMeta.icon = tab.icon;
247
-
248
- metaFiles.push({ dir: tab.slug, data: tabMeta });
249
- }
250
-
251
- if (rootPages.length > 0) {
252
- metaFiles.push({ dir: "", data: { pages: rootPages } });
253
- }
254
-
255
- return { pageMap, metaFiles, firstPage };
256
- }
257
-
258
- // ── Build ──────────────────────────────────────────────────────────────────────
259
-
260
- function build(docsDir: string, outDir: string) {
261
- console.log(`📖 Loading velu.json from: ${docsDir}`);
262
- const config = loadConfig(docsDir);
263
-
264
- if (existsSync(outDir)) {
265
- rmSync(outDir, { recursive: true, force: true });
266
- }
267
-
268
- // ── 1. Copy engine static files ──────────────────────────────────────────
269
- cpSync(ENGINE_DIR, outDir, { recursive: true });
270
- // Remove legacy Astro template leftovers if present in the packaged engine.
271
- rmSync(join(outDir, "src"), { recursive: true, force: true });
272
- console.log("📦 Copied engine files");
273
-
274
- // ── 2. Create additional directories ─────────────────────────────────────
275
- mkdirSync(join(outDir, "content", "docs"), { recursive: true });
276
- mkdirSync(join(outDir, "public"), { recursive: true });
277
-
278
- // ── 3. Copy velu.json into the generated project ─────────────────────────
279
- copyFileSync(join(docsDir, "velu.json"), join(outDir, "velu.json"));
280
- console.log("📋 Copied velu.json");
281
-
282
- // ── 4. Build content + metadata artifacts ────────────────────────────────
283
- const contentDir = join(outDir, "content", "docs");
284
- const navLanguages = config.navigation.languages;
285
- const simpleLanguages = config.languages || [];
286
-
287
- function processPage(srcPath: string, destPath: string, slug: string) {
288
- mkdirSync(dirname(destPath), { recursive: true });
289
- let content = readFileSync(srcPath, "utf-8");
290
- if (!content.startsWith("---")) {
291
- const titleMatch = content.match(/^#\s+(.+)$/m);
292
- const title = titleMatch ? titleMatch[1] : pageLabelFromSlug(slug);
293
- if (titleMatch) {
294
- content = content.replace(/^#\s+.+$/m, "").trimStart();
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);
295
993
  }
296
- content = `---\ntitle: "${title}"\n---\n\n${content}`;
297
994
  }
298
- writeFileSync(destPath, content, "utf-8");
299
- }
300
-
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
+
301
1072
  function writeLangContent(
302
1073
  langCode: string,
303
1074
  artifacts: BuildArtifacts,
304
1075
  isDefault: boolean,
305
1076
  useLangFolders = false
306
1077
  ) {
307
- const storagePrefix = useLangFolders ? langCode : (isDefault ? "" : langCode);
308
- const urlPrefix = isDefault ? "" : langCode;
309
-
310
- // Write meta files
311
- const metas = storagePrefix
312
- ? artifacts.metaFiles.map((m) => ({ dir: m.dir ? `${storagePrefix}/${m.dir}` : storagePrefix, data: { ...m.data } }))
313
- : artifacts.metaFiles;
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;
314
1085
  for (const meta of metas) {
315
1086
  const metaPath = join(contentDir, meta.dir, "meta.json");
316
1087
  mkdirSync(dirname(metaPath), { recursive: true });
317
1088
  writeFileSync(metaPath, JSON.stringify(meta.data, null, 2) + "\n", "utf-8");
318
1089
  }
319
1090
 
320
- // Copy pages using explicit source paths from velu.json
321
- for (const { src, dest } of artifacts.pageMap) {
322
- const srcPath = join(docsDir, `${src}.md`);
323
- if (!existsSync(srcPath)) {
324
- console.warn(`⚠️ Missing page source: ${src}.md (language: ${langCode})`);
325
- continue;
326
- }
327
- const destPath = join(contentDir, storagePrefix ? `${storagePrefix}/${dest}.mdx` : `${dest}.mdx`);
328
- processPage(srcPath, destPath, src);
1091
+ function sanitizeFrontmatterValue(value: string): string {
1092
+ return value.replace(/\r?\n+/g, " ").replace(/"/g, '\\"').trim();
329
1093
  }
330
1094
 
331
- // Index page
332
- const href = urlPrefix ? `/${urlPrefix}/${artifacts.firstPage}/` : `/${artifacts.firstPage}/`;
333
- const indexPath = storagePrefix ? join(contentDir, storagePrefix, "index.mdx") : join(contentDir, "index.mdx");
334
- writeFileSync(
335
- indexPath,
336
- `---\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`,
337
- "utf-8"
338
- );
339
- }
340
-
341
- let totalPages = 0;
342
- let totalMeta = 0;
343
-
344
- if (navLanguages && navLanguages.length > 0) {
345
- // ── Mode 1: Per-language navigation (Mintlify-style) ──────────────
346
- const rootPages: string[] = [];
347
-
348
- for (let i = 0; i < navLanguages.length; i++) {
349
- const langEntry = navLanguages[i];
350
- const isDefault = i === 0;
351
- const langConfig = { ...config, navigation: { ...config.navigation, tabs: langEntry.tabs } } as VeluConfig;
352
- const artifacts = buildArtifacts(langConfig);
353
- writeLangContent(langEntry.language, artifacts, isDefault, true);
354
- totalPages += artifacts.pageMap.length;
355
- totalMeta += artifacts.metaFiles.length;
356
- rootPages.push(`!${langEntry.language}`);
357
- }
358
-
359
- const rootMetaPath = join(contentDir, "meta.json");
360
- writeFileSync(rootMetaPath, JSON.stringify({ pages: rootPages }, null, 2) + "\n", "utf-8");
361
- } else {
362
- // ── Mode 2: Simple (single-lang or same-nav multi-lang) ───────────
363
- const artifacts = buildArtifacts(config);
364
- const useLangFolders = simpleLanguages.length > 1;
365
- writeLangContent(simpleLanguages[0] || "en", artifacts, true, useLangFolders);
366
- totalPages += artifacts.pageMap.length;
367
- totalMeta += artifacts.metaFiles.length;
368
-
369
- if (simpleLanguages.length > 1) {
370
- const rootMetaPath = join(contentDir, "meta.json");
371
- const rootPages = [`!${simpleLanguages[0] || "en"}`];
372
- for (const lang of simpleLanguages.slice(1)) {
373
- writeLangContent(lang, artifacts, false, true);
374
- rootPages.push(`!${lang}`);
375
- totalPages += artifacts.pageMap.length;
376
- totalMeta += artifacts.metaFiles.length;
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;
377
1131
  }
378
- writeFileSync(rootMetaPath, JSON.stringify({ pages: rootPages }, null, 2) + "\n", "utf-8");
379
- }
380
- }
381
-
382
- console.log(`📄 Generated ${totalPages} pages + ${totalMeta} navigation meta files`);
383
-
384
- // ── 5. Generate theme CSS (dynamic — depends on user config) ─────────────
385
- const themeCss = generateThemeCss({
386
- theme: config.theme,
387
- colors: config.colors,
388
- appearance: config.appearance,
389
- styling: config.styling,
390
- });
391
- writeFileSync(join(outDir, "app", "velu-theme.css"), themeCss, "utf-8");
392
- console.log(`🎨 Generated theme: ${resolveThemeName(config.theme)}`);
393
-
394
-
395
- // ── 7. Generate minimal package.json (type: module, no local deps) ───────
396
- const sitePkg = {
397
- name: "velu-docs-site",
398
- version: "0.0.1",
399
- private: true,
400
- type: "module",
401
- };
402
- writeFileSync(join(outDir, "package.json"), JSON.stringify(sitePkg, null, 2) + "\n", "utf-8");
403
-
404
- console.log("📦 Generated boilerplate");
405
- console.log(`\n✅ Site generated at: ${outDir}`);
406
- }
407
-
408
- export { build };
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 };