@aravindc26/velu 0.11.6 → 0.11.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/build.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { readFileSync, writeFileSync, mkdirSync, copyFileSync, cpSync, existsSync, rmSync, readdirSync } from "node:fs";
2
- import { join, dirname, relative, extname, resolve } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import { parse as parseYaml } from "yaml";
5
- import { generateThemeCss, resolveThemeName, type VeluColors, type VeluStyling } from "./themes.js";
6
- import { normalizeConfigNavigation } from "./navigation-normalize.js";
1
+ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, cpSync, existsSync, rmSync, readdirSync } from "node:fs";
2
+ import { join, dirname, relative, extname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { parse as parseYaml } from "yaml";
5
+ import { generateThemeCss, resolveThemeName, type VeluColors, type VeluStyling } from "./themes.js";
6
+ import { normalizeConfigNavigation } from "./navigation-normalize.js";
7
7
 
8
8
  // ── Engine directory (shipped with the CLI package) ──────────────────────────
9
9
  const __filename = fileURLToPath(import.meta.url);
@@ -24,7 +24,11 @@ const SOURCE_MIRROR_EXTENSIONS = new Set([
24
24
  ".pdf", ".txt", ".xml", ".csv", ".zip",
25
25
  ]);
26
26
 
27
- const IMPORT_REWRITE_EXTENSIONS = new Set([".md", ".mdx", ".jsx", ".js", ".tsx", ".ts"]);
27
+ const IMPORT_REWRITE_EXTENSIONS = new Set([".md", ".mdx", ".jsx", ".js", ".tsx", ".ts"]);
28
+ const VARIABLE_SUBSTITUTION_EXTENSIONS = new Set([
29
+ ".md", ".mdx", ".jsx", ".js", ".tsx", ".ts",
30
+ ".json", ".yaml", ".yml", ".css", ".txt", ".xml", ".csv",
31
+ ]);
28
32
 
29
33
  function resolveConfigPath(docsDir: string): string {
30
34
  const primary = join(docsDir, PRIMARY_CONFIG_NAME);
@@ -47,17 +51,17 @@ interface VeluLink {
47
51
  iconType?: string;
48
52
  }
49
53
 
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
- };
54
+ interface VeluAnchor {
55
+ anchor: string;
56
+ href?: string;
57
+ icon?: string;
58
+ iconType?: string;
59
+ openapi?: VeluOpenApiSource;
60
+ version?: string;
61
+ color?: {
62
+ light: string;
63
+ dark: string;
64
+ };
61
65
  tabs?: VeluTab[];
62
66
  hidden?: boolean;
63
67
  }
@@ -69,16 +73,16 @@ interface VeluGlobalTab {
69
73
  iconType?: string;
70
74
  }
71
75
 
72
- interface VeluGroup {
73
- group: string;
74
- slug: string;
75
- icon?: string;
76
- iconType?: string;
77
- version?: string;
78
- openapi?: VeluOpenApiSource;
79
- expanded?: boolean;
80
- description?: string;
81
- hidden?: boolean;
76
+ interface VeluGroup {
77
+ group: string;
78
+ slug: string;
79
+ icon?: string;
80
+ iconType?: string;
81
+ version?: string;
82
+ openapi?: VeluOpenApiSource;
83
+ expanded?: boolean;
84
+ description?: string;
85
+ hidden?: boolean;
82
86
  pages: (string | VeluGroup | VeluSeparator | VeluLink)[];
83
87
  }
84
88
 
@@ -91,17 +95,17 @@ interface VeluMenuItem {
91
95
  pages?: (string | VeluSeparator | VeluLink)[];
92
96
  }
93
97
 
94
- interface VeluTab {
95
- tab: string;
96
- slug: string;
97
- icon?: string;
98
- iconType?: string;
99
- href?: string;
100
- openapi?: VeluOpenApiSource;
101
- version?: string;
102
- pages?: (string | VeluSeparator | VeluLink)[];
103
- groups?: VeluGroup[];
104
- menu?: VeluMenuItem[];
98
+ interface VeluTab {
99
+ tab: string;
100
+ slug: string;
101
+ icon?: string;
102
+ iconType?: string;
103
+ href?: string;
104
+ openapi?: VeluOpenApiSource;
105
+ version?: string;
106
+ pages?: (string | VeluSeparator | VeluLink)[];
107
+ groups?: VeluGroup[];
108
+ menu?: VeluMenuItem[];
105
109
  }
106
110
 
107
111
  interface VeluLanguageNav {
@@ -131,15 +135,19 @@ interface VeluRedirect {
131
135
  permanent?: boolean;
132
136
  }
133
137
 
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[];
138
+ interface VeluConfig {
139
+ $schema?: string;
140
+ theme?: string;
141
+ variables?: Record<string, string>;
142
+ colors?: VeluColors;
143
+ appearance?: "system" | "light" | "dark";
144
+ styling?: VeluStyling;
145
+ metadata?: {
146
+ timestamp?: boolean;
147
+ };
148
+ openapi?: VeluOpenApiSource;
149
+ languages?: string[];
150
+ redirects?: VeluRedirect[];
143
151
  navigation: {
144
152
  openapi?: VeluOpenApiSource;
145
153
  tabs?: VeluTab[];
@@ -154,12 +162,12 @@ interface VeluConfig {
154
162
  };
155
163
  }
156
164
 
157
- interface VeluOpenApiConfigObject {
158
- source?: string | string[];
159
- directory?: string;
160
- }
161
-
162
- type VeluOpenApiSource = string | string[] | VeluOpenApiConfigObject;
165
+ interface VeluOpenApiConfigObject {
166
+ source?: string | string[];
167
+ directory?: string;
168
+ }
169
+
170
+ type VeluOpenApiSource = string | string[] | VeluOpenApiConfigObject;
163
171
 
164
172
  function isSeparator(item: unknown): item is VeluSeparator {
165
173
  return typeof item === "object" && item !== null && "separator" in item;
@@ -173,67 +181,67 @@ function isGroup(item: unknown): item is VeluGroup {
173
181
  return typeof item === "object" && item !== null && "group" in item;
174
182
  }
175
183
 
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;
191
- }
192
-
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;
204
- }
205
-
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
- }
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
- }
184
+ const HTTP_METHODS = new Set([
185
+ "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "TRACE", "CONNECT", "WEBHOOK",
186
+ ]);
187
+ const OPENAPI_PATH_METHODS = new Set(["get", "post", "put", "patch", "delete", "options", "head", "trace"]);
188
+
189
+ interface ParsedOpenApiOperationRef {
190
+ spec?: string;
191
+ method: string;
192
+ endpoint: string;
193
+ kind?: "path" | "webhook";
194
+ title?: string;
195
+ description?: string;
196
+ deprecated?: boolean;
197
+ version?: string;
198
+ content?: string;
199
+ }
200
+
201
+ function resolveDefaultOpenApiSpec(openapi: VeluOpenApiSource | undefined): string | undefined {
202
+ const source = extractOpenApiSource(openapi);
203
+ if (typeof source === "string") {
204
+ const trimmed = source.trim();
205
+ return trimmed.length > 0 ? trimmed : undefined;
206
+ }
207
+ if (Array.isArray(source)) {
208
+ const first = source.find((entry) => typeof entry === "string" && entry.trim().length > 0);
209
+ return typeof first === "string" ? first.trim() : undefined;
210
+ }
211
+ return undefined;
212
+ }
235
213
 
236
- function slugFromOpenApiOperation(method: string, endpoint: string): string {
214
+ function parseOpenApiOperationRef(value: string, inheritedSpec?: string): ParsedOpenApiOperationRef | null {
215
+ const trimmed = value.trim();
216
+ if (!trimmed) return null;
217
+
218
+ const withSpec = trimmed.match(/^(\S+)\s+([A-Za-z]+)\s+(.+)$/);
219
+ if (withSpec) {
220
+ const method = withSpec[2].toUpperCase();
221
+ const endpoint = withSpec[3].trim();
222
+ if (!HTTP_METHODS.has(method)) return null;
223
+ if (method === "WEBHOOK") {
224
+ if (!endpoint) return null;
225
+ return { spec: withSpec[1].trim(), method, endpoint, kind: "webhook" };
226
+ }
227
+ if (!endpoint.startsWith("/")) return null;
228
+ return { spec: withSpec[1].trim(), method, endpoint, kind: "path" };
229
+ }
230
+
231
+ const noSpec = trimmed.match(/^([A-Za-z]+)\s+(.+)$/);
232
+ if (!noSpec) return null;
233
+ const method = noSpec[1].toUpperCase();
234
+ const endpoint = noSpec[2].trim();
235
+ if (!HTTP_METHODS.has(method)) return null;
236
+ if (method === "WEBHOOK") {
237
+ if (!endpoint) return null;
238
+ return { spec: inheritedSpec, method, endpoint, kind: "webhook" };
239
+ }
240
+ if (!endpoint.startsWith("/")) return null;
241
+ return { spec: inheritedSpec, method, endpoint, kind: "path" };
242
+ }
243
+
244
+ function slugFromOpenApiOperation(method: string, endpoint: string): string {
237
245
  const cleaned = endpoint
238
246
  .toLowerCase()
239
247
  .replace(/^\/+/, "")
@@ -243,164 +251,266 @@ function slugFromOpenApiOperation(method: string, endpoint: string): string {
243
251
  .replace(/[-_.]{2,}/g, "-")
244
252
  .replace(/^[-_.]+|[-_.]+$/g, "");
245
253
  const body = cleaned || "endpoint";
246
- return `${method.toLowerCase()}-${body}`;
247
- }
254
+ return `${method.toLowerCase()}-${body}`;
255
+ }
256
+
257
+ function resolveOpenApiSpecList(openapi: VeluOpenApiSource | undefined): string[] {
258
+ const source = extractOpenApiSource(openapi);
259
+ if (typeof source === "string") {
260
+ const trimmed = source.trim();
261
+ return trimmed ? [trimmed] : [];
262
+ }
263
+ if (Array.isArray(source)) {
264
+ return source
265
+ .filter((entry): entry is string => typeof entry === "string")
266
+ .map((entry) => entry.trim())
267
+ .filter((entry) => entry.length > 0);
268
+ }
269
+ return [];
270
+ }
271
+
272
+ function extractOpenApiSource(openapi: VeluOpenApiSource | undefined): string | string[] | undefined {
273
+ if (typeof openapi === "string" || Array.isArray(openapi)) return openapi;
274
+ if (openapi && typeof openapi === "object") {
275
+ const source = (openapi as VeluOpenApiConfigObject).source;
276
+ if (typeof source === "string" || Array.isArray(source)) return source;
277
+ }
278
+ return undefined;
279
+ }
280
+
281
+ function resolveOpenApiDirectory(openapi: VeluOpenApiSource | undefined): string | undefined {
282
+ if (!openapi || typeof openapi !== "object" || Array.isArray(openapi)) return undefined;
283
+ const raw = (openapi as VeluOpenApiConfigObject).directory;
284
+ if (typeof raw !== "string") return undefined;
285
+ const trimmed = raw.trim().replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
286
+ return trimmed.length > 0 ? trimmed : undefined;
287
+ }
288
+
289
+ function parseOpenApiDocument(rawSource: string): Record<string, unknown> | null {
290
+ const source = rawSource.trim();
291
+ if (!source) return null;
292
+ try {
293
+ const parsed = JSON.parse(source);
294
+ if (parsed && typeof parsed === "object") return parsed as Record<string, unknown>;
295
+ } catch {
296
+ // fall through and attempt YAML parse.
297
+ }
298
+ try {
299
+ const parsed = parseYaml(source);
300
+ if (parsed && typeof parsed === "object") return parsed as Record<string, unknown>;
301
+ } catch {
302
+ return null;
303
+ }
304
+ return null;
305
+ }
306
+
307
+ function readMintMetadata(operation: Record<string, unknown>) {
308
+ const xMint = operation["x-mint"];
309
+ if (!xMint || typeof xMint !== "object") return {};
310
+ const metadata = (xMint as Record<string, unknown>).metadata;
311
+ const content = (xMint as Record<string, unknown>).content;
312
+ const meta = metadata && typeof metadata === "object" ? (metadata as Record<string, unknown>) : {};
313
+ return {
314
+ title: typeof meta.title === "string" ? meta.title : undefined,
315
+ description: typeof meta.description === "string" ? meta.description : undefined,
316
+ deprecated: typeof meta.deprecated === "boolean" ? meta.deprecated : undefined,
317
+ version: typeof meta.version === "string" ? meta.version : undefined,
318
+ content: typeof content === "string" ? content : undefined,
319
+ };
320
+ }
321
+
322
+ function pickOperationMethod(pathItem: Record<string, unknown>): string | undefined {
323
+ for (const method of OPENAPI_PATH_METHODS) {
324
+ const operation = pathItem[method];
325
+ if (operation && typeof operation === "object") return method.toUpperCase();
326
+ }
327
+ return undefined;
328
+ }
329
+
330
+ function loadOpenApiOperations(specSource: string, docsDir: string): ParsedOpenApiOperationRef[] {
331
+ if (/^https?:\/\//i.test(specSource) || specSource.startsWith("file://")) return [];
332
+
333
+ const resolvedPath = specSource.startsWith("/")
334
+ ? join(docsDir, specSource.replace(/^\/+/, ""))
335
+ : resolve(docsDir, specSource);
336
+ if (!existsSync(resolvedPath)) return [];
337
+
338
+ const parsed = parseOpenApiDocument(readFileSync(resolvedPath, "utf-8"));
339
+ if (!parsed) return [];
340
+
341
+ const paths = parsed.paths;
342
+ const webhooks = parsed.webhooks;
343
+
344
+ const output: ParsedOpenApiOperationRef[] = [];
345
+ if (paths && typeof paths === "object") {
346
+ for (const [endpoint, methods] of Object.entries(paths as Record<string, unknown>)) {
347
+ if (!endpoint.startsWith("/") || !methods || typeof methods !== "object") continue;
348
+ for (const method of Object.keys(methods as Record<string, unknown>)) {
349
+ const normalized = method.toLowerCase();
350
+ if (!OPENAPI_PATH_METHODS.has(normalized)) continue;
351
+ const operation = (methods as Record<string, unknown>)[method];
352
+ if (!operation || typeof operation !== "object") continue;
353
+ if ((operation as Record<string, unknown>)["x-hidden"] === true) continue;
354
+ const mintMeta = readMintMetadata(operation as Record<string, unknown>);
355
+ output.push({
356
+ kind: "path",
357
+ spec: specSource,
358
+ method: normalized.toUpperCase(),
359
+ endpoint,
360
+ title: mintMeta.title ?? (typeof (operation as Record<string, unknown>).summary === "string" ? String((operation as Record<string, unknown>).summary) : undefined),
361
+ description: mintMeta.description ?? (typeof (operation as Record<string, unknown>).description === "string" ? String((operation as Record<string, unknown>).description) : undefined),
362
+ deprecated: mintMeta.deprecated ?? ((operation as Record<string, unknown>).deprecated === true),
363
+ version: mintMeta.version,
364
+ content: mintMeta.content,
365
+ });
366
+ }
367
+ }
368
+ }
369
+
370
+ if (webhooks && typeof webhooks === "object") {
371
+ for (const [webhookName, pathItem] of Object.entries(webhooks as Record<string, unknown>)) {
372
+ if (!pathItem || typeof pathItem !== "object") continue;
373
+ const resolvedMethod = pickOperationMethod(pathItem as Record<string, unknown>);
374
+ if (!resolvedMethod) continue;
375
+ const operation = (pathItem as Record<string, unknown>)[resolvedMethod.toLowerCase()];
376
+ if (!operation || typeof operation !== "object") continue;
377
+ if ((operation as Record<string, unknown>)["x-hidden"] === true) continue;
378
+ const mintMeta = readMintMetadata(operation as Record<string, unknown>);
379
+ output.push({
380
+ kind: "webhook",
381
+ spec: specSource,
382
+ method: "WEBHOOK",
383
+ endpoint: webhookName,
384
+ title: mintMeta.title ?? (typeof (operation as Record<string, unknown>).summary === "string" ? String((operation as Record<string, unknown>).summary) : undefined),
385
+ description: mintMeta.description ?? (typeof (operation as Record<string, unknown>).description === "string" ? String((operation as Record<string, unknown>).description) : undefined),
386
+ deprecated: mintMeta.deprecated ?? ((operation as Record<string, unknown>).deprecated === true),
387
+ version: mintMeta.version,
388
+ content: mintMeta.content,
389
+ });
390
+ }
391
+ }
392
+ return output;
393
+ }
394
+
395
+ function normalizeOpenApiSpecForFrontmatter(spec: string | undefined): string | undefined {
396
+ if (!spec) return undefined;
397
+ const trimmed = spec.trim();
398
+ if (!trimmed) return undefined;
399
+ if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith("file://")) return trimmed;
400
+ if (trimmed.startsWith("/")) return trimmed;
401
+ return `/${trimmed.replace(/^\.?\/*/, "")}`;
402
+ }
403
+
404
+ // ── Helpers ────────────────────────────────────────────────────────────────────
248
405
 
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 [];
262
- }
406
+ const VARIABLE_TOKEN_PATTERN = /\{\{\s*([A-Za-z0-9.-]+)\s*\}\}/g;
407
+ const VARIABLE_NAME_PATTERN = /^[A-Za-z0-9.-]+$/;
263
408
 
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;
409
+ function sanitizeVariableValue(value: string): string {
410
+ return value.replace(/</g, "&lt;").replace(/>/g, "&gt;");
271
411
  }
272
412
 
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;
279
- }
413
+ function extractVariables(input: unknown): Record<string, string> {
414
+ if (!input || typeof input !== "object" || Array.isArray(input)) return {};
280
415
 
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;
416
+ const output: Record<string, string> = {};
417
+ for (const [rawKey, rawValue] of Object.entries(input as Record<string, unknown>)) {
418
+ const key = rawKey.trim();
419
+ if (!key) continue;
420
+ if (!VARIABLE_NAME_PATTERN.test(key)) {
421
+ throw new Error(`Invalid variable name '${rawKey}'. Variable names can only contain letters, numbers, periods, and hyphens.`);
422
+ }
423
+ if (typeof rawValue !== "string") {
424
+ throw new Error(`Invalid value for variable '${rawKey}'. Variables must be strings.`);
425
+ }
426
+ output[key] = rawValue;
295
427
  }
296
- return null;
428
+ return output;
297
429
  }
298
430
 
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
- }
431
+ function resolveVariableMap(rawVariables: Record<string, string>): Record<string, string> {
432
+ const cache = new Map<string, string>();
433
+ const activeStack = new Set<string>();
313
434
 
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
- }
435
+ function resolveOne(name: string): string {
436
+ const cached = cache.get(name);
437
+ if (cached !== undefined) return cached;
321
438
 
322
- function loadOpenApiOperations(specSource: string, docsDir: string): ParsedOpenApiOperationRef[] {
323
- if (/^https?:\/\//i.test(specSource) || specSource.startsWith("file://")) return [];
439
+ if (activeStack.has(name)) {
440
+ throw new Error(`Circular variable reference detected for '{{${name}}}'.`);
441
+ }
324
442
 
325
- const resolvedPath = specSource.startsWith("/")
326
- ? join(docsDir, specSource.replace(/^\/+/, ""))
327
- : resolve(docsDir, specSource);
328
- if (!existsSync(resolvedPath)) return [];
443
+ const raw = rawVariables[name];
444
+ if (raw === undefined) {
445
+ throw new Error(`Undefined variable '{{${name}}}' referenced in variable definitions.`);
446
+ }
329
447
 
330
- const parsed = parseOpenApiDocument(readFileSync(resolvedPath, "utf-8"));
331
- if (!parsed) return [];
448
+ activeStack.add(name);
449
+ const resolved = raw.replace(VARIABLE_TOKEN_PATTERN, (_match, token: string) => resolveOne(token));
450
+ activeStack.delete(name);
451
+ cache.set(name, resolved);
452
+ return resolved;
453
+ }
332
454
 
333
- const paths = parsed.paths;
334
- const webhooks = parsed.webhooks;
455
+ const output: Record<string, string> = {};
456
+ for (const name of Object.keys(rawVariables)) {
457
+ output[name] = resolveOne(name);
458
+ }
459
+ return output;
460
+ }
335
461
 
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
- }
462
+ function replaceVariablesInString(
463
+ value: string,
464
+ variables: Record<string, string>,
465
+ context: string,
466
+ sanitizeValues: boolean,
467
+ ): string {
468
+ const undefinedVariables = new Set<string>();
469
+ const replaced = value.replace(VARIABLE_TOKEN_PATTERN, (match, rawName: string) => {
470
+ const name = rawName.trim();
471
+ const resolved = variables[name];
472
+ if (resolved === undefined) {
473
+ undefinedVariables.add(name);
474
+ return match;
359
475
  }
476
+ return sanitizeValues ? sanitizeVariableValue(resolved) : resolved;
477
+ });
478
+
479
+ if (undefinedVariables.size > 0) {
480
+ throw new Error(
481
+ `Undefined variable(s) ${Array.from(undefinedVariables).map((name) => `'{{${name}}}'`).join(", ")} in ${context}.`
482
+ );
360
483
  }
361
484
 
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
- }
485
+ return replaced;
486
+ }
487
+
488
+ function applyVariablesToConfig(value: unknown, variables: Record<string, string>, path = "docs.json"): unknown {
489
+ if (typeof value === "string") return replaceVariablesInString(value, variables, path, false);
490
+ if (Array.isArray(value)) return value.map((entry, index) => applyVariablesToConfig(entry, variables, `${path}[${index}]`));
491
+ if (!value || typeof value !== "object") return value;
492
+
493
+ const output: Record<string, unknown> = {};
494
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
495
+ output[key] = applyVariablesToConfig(entry, variables, `${path}.${key}`);
383
496
  }
384
497
  return output;
385
498
  }
386
499
 
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(/^\.?\/*/, "")}`;
500
+ function loadConfig(docsDir: string): { config: VeluConfig; rawConfig: VeluConfig; variables: Record<string, string> } {
501
+ const raw = readFileSync(resolveConfigPath(docsDir), "utf-8");
502
+ const parsed = JSON.parse(raw) as VeluConfig;
503
+ const rawVariables = extractVariables(parsed.variables);
504
+ const resolvedVariables = resolveVariableMap(rawVariables);
505
+ const withVariables = applyVariablesToConfig(parsed, resolvedVariables) as VeluConfig;
506
+ withVariables.variables = resolvedVariables;
507
+ return {
508
+ config: normalizeConfigNavigation(withVariables),
509
+ rawConfig: withVariables,
510
+ variables: resolvedVariables,
511
+ };
394
512
  }
395
513
 
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
514
  function isExternalDestination(value: string): boolean {
405
515
  return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value);
406
516
  }
@@ -673,27 +783,41 @@ function rewriteImportsInContent(
673
783
  return out.join("\n");
674
784
  }
675
785
 
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 });
786
+ function copyMirroredSourceFile(
787
+ srcPath: string,
788
+ docsDir: string,
789
+ mirrorDir: string,
790
+ variables: Record<string, string>,
791
+ ) {
792
+ if (!shouldMirrorSourceFile(srcPath)) return;
793
+ if (!isInsideDocsRoot(docsDir, srcPath)) return;
794
+
795
+ const relPath = relative(docsDir, srcPath);
796
+ const destPath = join(mirrorDir, relPath);
797
+ mkdirSync(dirname(destPath), { recursive: true });
798
+
799
+ if (shouldRewriteImports(srcPath)) {
800
+ let raw = readFileSync(srcPath, "utf-8");
801
+ raw = replaceVariablesInString(raw, variables, relPath, true);
802
+ const rewritten = rewriteImportsInContent(raw, srcPath, destPath, docsDir, mirrorDir);
803
+ writeFileSync(destPath, rewritten, "utf-8");
804
+ return;
805
+ }
806
+
807
+ const extension = extname(srcPath).toLowerCase();
808
+ if (VARIABLE_SUBSTITUTION_EXTENSIONS.has(extension)) {
809
+ const raw = readFileSync(srcPath, "utf-8");
810
+ const substituted = replaceVariablesInString(raw, variables, relPath, true);
811
+ writeFileSync(destPath, substituted, "utf-8");
812
+ return;
813
+ }
814
+
815
+ copyFileSync(srcPath, destPath);
816
+ }
817
+
818
+ function rebuildSourceMirror(docsDir: string, mirrorDir: string, variables: Record<string, string>) {
819
+ rmSync(mirrorDir, { recursive: true, force: true });
820
+ mkdirSync(mirrorDir, { recursive: true });
697
821
 
698
822
  function walk(dir: string) {
699
823
  const entries = readdirSync(dir, { withFileTypes: true });
@@ -701,14 +825,14 @@ function rebuildSourceMirror(docsDir: string, mirrorDir: string) {
701
825
  if (entry.name.startsWith(".")) continue;
702
826
  if (entry.name === "node_modules") continue;
703
827
  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
- }
828
+ if (entry.isDirectory()) {
829
+ walk(srcPath);
830
+ continue;
831
+ }
832
+ if (!shouldMirrorSourceFile(srcPath)) continue;
833
+ copyMirroredSourceFile(srcPath, docsDir, mirrorDir, variables);
834
+ }
835
+ }
712
836
 
713
837
  walk(docsDir);
714
838
  }
@@ -722,20 +846,20 @@ function pageBasename(page: string): string {
722
846
  return page.split("/").pop()!;
723
847
  }
724
848
 
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
- }
849
+ interface PageMapping {
850
+ src: string; // original page reference
851
+ dest: string; // destination path under content/docs (without extension)
852
+ kind: "file" | "openapi-operation";
853
+ openapiSpec?: string;
854
+ openapiMethod?: string;
855
+ openapiEndpoint?: string;
856
+ openapiKind?: "path" | "webhook";
857
+ title?: string;
858
+ description?: string;
859
+ deprecated?: boolean;
860
+ version?: string;
861
+ content?: string;
862
+ }
739
863
 
740
864
  interface MetaFile {
741
865
  dir: string;
@@ -748,7 +872,7 @@ interface BuildArtifacts {
748
872
  firstPage: string;
749
873
  }
750
874
 
751
- function buildArtifacts(config: VeluConfig, docsDirForOpenApi?: string): BuildArtifacts {
875
+ function buildArtifacts(config: VeluConfig, docsDirForOpenApi?: string): BuildArtifacts {
752
876
  const pageMap: PageMapping[] = [];
753
877
  const metaFiles: MetaFile[] = [];
754
878
  const rootTabs = (config.navigation.tabs || []).filter((tab) => !tab.href);
@@ -776,173 +900,173 @@ function buildArtifacts(config: VeluConfig, docsDirForOpenApi?: string): BuildAr
776
900
  return String(item);
777
901
  }
778
902
 
779
- function uniqueDestination(dest: string): string {
780
- if (!usedDestinations.has(dest)) {
781
- usedDestinations.add(dest);
782
- return dest;
903
+ function uniqueDestination(dest: string): string {
904
+ if (!usedDestinations.has(dest)) {
905
+ usedDestinations.add(dest);
906
+ return dest;
783
907
  }
784
908
  let count = 2;
785
909
  while (usedDestinations.has(`${dest}-${count}`)) count += 1;
786
910
  const candidate = `${dest}-${count}`;
787
- usedDestinations.add(candidate);
788
- return candidate;
789
- }
790
-
791
- function metaEntryForDestination(baseDir: string, destination: string): string {
792
- const fromParts = baseDir.split("/").filter(Boolean);
793
- const toParts = destination.split("/").filter(Boolean);
794
-
795
- let index = 0;
796
- while (index < fromParts.length && index < toParts.length && fromParts[index] === toParts[index]) {
797
- index += 1;
798
- }
799
-
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
- }
911
+ usedDestinations.add(candidate);
912
+ return candidate;
913
+ }
813
914
 
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
- }
915
+ function metaEntryForDestination(baseDir: string, destination: string): string {
916
+ const fromParts = baseDir.split("/").filter(Boolean);
917
+ const toParts = destination.split("/").filter(Boolean);
918
+
919
+ let index = 0;
920
+ while (index < fromParts.length && index < toParts.length && fromParts[index] === toParts[index]) {
921
+ index += 1;
922
+ }
923
+
924
+ const up = Array(fromParts.length - index).fill("..");
925
+ const down = toParts.slice(index);
926
+ const rel = [...up, ...down].join("/");
927
+ return rel || pageBasename(destination);
928
+ }
929
+
930
+ function resolveGenerationDestination(openapi: VeluOpenApiSource | undefined, fallback: string): string {
931
+ const override = resolveOpenApiDirectory(openapi);
932
+ if (!override) return fallback;
933
+ if (!fallback) return override;
934
+ if (override === fallback || override.startsWith(`${fallback}/`)) return override;
935
+ return `${fallback}/${override}`;
936
+ }
937
+
938
+ function toPageMapping(item: string, destDir: string, inheritedSpec?: string): PageMapping {
939
+ const parsedOpenApi = parseOpenApiOperationRef(item, inheritedSpec);
940
+ if (!parsedOpenApi) {
941
+ const basename = pageBasename(item);
942
+ const dest = uniqueDestination(`${destDir}/${basename}`);
943
+ return { src: item, dest, kind: "file" };
944
+ }
821
945
 
822
946
  const slug = slugFromOpenApiOperation(parsedOpenApi.method, parsedOpenApi.endpoint);
823
947
  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
- };
838
- }
839
-
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;
854
- }
855
- return mapping;
856
- }
857
-
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
- ) {
911
- const groupDir = `${parentDir}/${group.slug}`;
912
- const pages: string[] = [];
913
- const openApiSpec = resolveDefaultOpenApiSpec(group.openapi) ?? inheritedOpenApiSpec;
914
- const groupVersion = resolveInheritedVersion(group.version, inheritedVersion);
915
-
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)) {
948
+ return {
949
+ src: item,
950
+ dest,
951
+ kind: "openapi-operation",
952
+ openapiSpec: parsedOpenApi.spec,
953
+ openapiMethod: parsedOpenApi.method,
954
+ openapiEndpoint: parsedOpenApi.endpoint,
955
+ openapiKind: parsedOpenApi.kind,
956
+ title: parsedOpenApi.title,
957
+ description: parsedOpenApi.description,
958
+ deprecated: parsedOpenApi.deprecated,
959
+ version: parsedOpenApi.version,
960
+ content: parsedOpenApi.content,
961
+ };
962
+ }
963
+
964
+ function resolveInheritedVersion(value: unknown, inherited?: string): string | undefined {
965
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
966
+ return inherited;
967
+ }
968
+
969
+ function toPageMappingWithVersion(
970
+ item: string,
971
+ destDir: string,
972
+ inheritedSpec?: string,
973
+ inheritedVersion?: string,
974
+ ): PageMapping {
975
+ const mapping = toPageMapping(item, destDir, inheritedSpec);
976
+ if (mapping.kind === "openapi-operation" && mapping.version === undefined) {
977
+ mapping.version = inheritedVersion;
978
+ }
979
+ return mapping;
980
+ }
981
+
982
+ function toOperationMapping(
983
+ ref: ParsedOpenApiOperationRef,
984
+ destDir: string,
985
+ inheritedVersion?: string,
986
+ ): PageMapping {
987
+ const slug = slugFromOpenApiOperation(ref.method, ref.endpoint);
988
+ const dest = uniqueDestination(`${destDir}/${slug}`);
989
+ return {
990
+ src: `${ref.spec ? `${ref.spec} ` : ""}${ref.method} ${ref.endpoint}`,
991
+ dest,
992
+ kind: "openapi-operation",
993
+ openapiSpec: ref.spec,
994
+ openapiMethod: ref.method,
995
+ openapiEndpoint: ref.endpoint,
996
+ openapiKind: ref.kind,
997
+ title: ref.title,
998
+ description: ref.description,
999
+ deprecated: ref.deprecated,
1000
+ version: ref.version ?? inheritedVersion,
1001
+ content: ref.content,
1002
+ };
1003
+ }
1004
+
1005
+ function buildOpenApiMappings(
1006
+ openapi: VeluOpenApiSource | undefined,
1007
+ destDir: string,
1008
+ fallbackSpec?: string,
1009
+ inheritedVersion?: string,
1010
+ ): PageMapping[] {
1011
+ if (!docsDirForOpenApi) return [];
1012
+ const specs = resolveOpenApiSpecList(openapi);
1013
+ if (specs.length === 0 && fallbackSpec) specs.push(fallbackSpec);
1014
+ if (specs.length === 0) return [];
1015
+
1016
+ const output: PageMapping[] = [];
1017
+ const seen = new Set<string>();
1018
+ for (const spec of specs) {
1019
+ for (const operation of loadOpenApiOperations(spec, docsDirForOpenApi)) {
1020
+ const key = `${operation.spec ?? ""}::${operation.kind ?? "path"}::${operation.method}::${operation.endpoint}`;
1021
+ if (seen.has(key)) continue;
1022
+ seen.add(key);
1023
+ output.push(toOperationMapping(operation, destDir, inheritedVersion));
1024
+ }
1025
+ }
1026
+ return output;
1027
+ }
1028
+
1029
+ function addGroup(
1030
+ group: VeluGroup,
1031
+ parentDir: string,
1032
+ inheritedOpenApiSpec?: string,
1033
+ inheritedVersion?: string,
1034
+ ) {
1035
+ const groupDir = `${parentDir}/${group.slug}`;
1036
+ const pages: string[] = [];
1037
+ const openApiSpec = resolveDefaultOpenApiSpec(group.openapi) ?? inheritedOpenApiSpec;
1038
+ const groupVersion = resolveInheritedVersion(group.version, inheritedVersion);
1039
+
1040
+ const groupPageItems = Array.isArray(group.pages) ? group.pages : [];
1041
+ for (const item of groupPageItems) {
1042
+ if (typeof item === "string") {
1043
+ const mapping = toPageMappingWithVersion(item, groupDir, openApiSpec, groupVersion);
1044
+ pageMap.push(mapping);
1045
+ pages.push(metaEntryForDestination(groupDir, mapping.dest));
1046
+ trackFirstPage(mapping.dest);
1047
+ } else if (isGroup(item)) {
1048
+ addGroup(item, groupDir, openApiSpec, groupVersion);
1049
+ pages.push(item.hidden ? `!${item.slug}` : item.slug);
1050
+ } else if (isSeparator(item)) {
1051
+ pages.push(`---${item.separator}---`);
1052
+ } else if (isLink(item)) {
929
1053
  pages.push(
930
1054
  item.icon
931
1055
  ? `[${item.icon}][${item.label}](${item.href})`
932
1056
  : `[${item.label}](${item.href})`
933
1057
  );
934
- }
935
- }
936
-
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
- }
1058
+ }
1059
+ }
1060
+
1061
+ if (groupPageItems.length === 0 && group.openapi !== undefined) {
1062
+ const generatedDestDir = resolveGenerationDestination(group.openapi, groupDir);
1063
+ const generatedMappings = buildOpenApiMappings(group.openapi, generatedDestDir, openApiSpec, groupVersion);
1064
+ for (const mapping of generatedMappings) {
1065
+ pageMap.push(mapping);
1066
+ pages.push(metaEntryForDestination(groupDir, mapping.dest));
1067
+ trackFirstPage(mapping.dest);
1068
+ }
1069
+ }
946
1070
 
947
1071
  const groupMeta: Record<string, unknown> = {
948
1072
  title: group.group,
@@ -957,41 +1081,41 @@ function buildArtifacts(config: VeluConfig, docsDirForOpenApi?: string): BuildAr
957
1081
  metaFiles.push({ dir: groupDir, data: groupMeta });
958
1082
  }
959
1083
 
960
- for (const tab of rootTabs) {
961
- const tabPages: string[] = [];
962
- const tabOpenApiSpec = resolveDefaultOpenApiSpec(tab.openapi) ?? defaultOpenApiSpec;
963
- const tabVersion = resolveInheritedVersion(tab.version);
964
-
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
- }
1084
+ for (const tab of rootTabs) {
1085
+ const tabPages: string[] = [];
1086
+ const tabOpenApiSpec = resolveDefaultOpenApiSpec(tab.openapi) ?? defaultOpenApiSpec;
1087
+ const tabVersion = resolveInheritedVersion(tab.version);
971
1088
 
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
- }
983
- }
984
- }
985
-
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
- }
1089
+ if (tab.groups) {
1090
+ for (const group of tab.groups) {
1091
+ addGroup(group, tab.slug, tabOpenApiSpec, tabVersion);
1092
+ tabPages.push(group.hidden ? `!${group.slug}` : group.slug);
1093
+ }
1094
+ }
1095
+
1096
+ const tabPageItems = Array.isArray(tab.pages) ? tab.pages : [];
1097
+ if (tabPageItems.length > 0) {
1098
+ for (const item of tabPageItems) {
1099
+ if (typeof item === "string") {
1100
+ const mapping = toPageMappingWithVersion(item, tab.slug, tabOpenApiSpec, tabVersion);
1101
+ pageMap.push(mapping);
1102
+ tabPages.push(metaEntryForDestination(tab.slug, mapping.dest));
1103
+ trackFirstPage(mapping.dest);
1104
+ } else {
1105
+ tabPages.push(metaEntry(item));
1106
+ }
1107
+ }
1108
+ }
1109
+
1110
+ if ((tab.groups?.length ?? 0) === 0 && tabPageItems.length === 0 && tab.openapi !== undefined) {
1111
+ const generatedDestDir = resolveGenerationDestination(tab.openapi, tab.slug);
1112
+ const generatedMappings = buildOpenApiMappings(tab.openapi, generatedDestDir, tabOpenApiSpec, tabVersion);
1113
+ for (const mapping of generatedMappings) {
1114
+ pageMap.push(mapping);
1115
+ tabPages.push(metaEntryForDestination(tab.slug, mapping.dest));
1116
+ trackFirstPage(mapping.dest);
1117
+ }
1118
+ }
995
1119
 
996
1120
  const tabMeta: Record<string, unknown> = {
997
1121
  title: tab.tab,
@@ -1014,11 +1138,11 @@ function buildArtifacts(config: VeluConfig, docsDirForOpenApi?: string): BuildAr
1014
1138
 
1015
1139
  // ── Build ──────────────────────────────────────────────────────────────────────
1016
1140
 
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);
1141
+ function build(docsDir: string, outDir: string) {
1142
+ const configPath = resolveConfigPath(docsDir);
1143
+ const configName = configPath.endsWith(PRIMARY_CONFIG_NAME) ? PRIMARY_CONFIG_NAME : LEGACY_CONFIG_NAME;
1144
+ console.log(`📖 Loading ${configName} from: ${docsDir}`);
1145
+ const { config, rawConfig, variables } = loadConfig(docsDir);
1022
1146
 
1023
1147
  if (existsSync(outDir)) {
1024
1148
  rmSync(outDir, { recursive: true, force: true });
@@ -1031,15 +1155,16 @@ function build(docsDir: string, outDir: string) {
1031
1155
  console.log("📦 Copied engine files");
1032
1156
 
1033
1157
  // ── 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);
1158
+ mkdirSync(join(outDir, "content", "docs"), { recursive: true });
1159
+ mkdirSync(join(outDir, "public"), { recursive: true });
1160
+ const sourceMirrorDir = join(outDir, SOURCE_MIRROR_DIR);
1161
+ rebuildSourceMirror(docsDir, sourceMirrorDir, variables);
1038
1162
 
1039
1163
  // ── 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})`);
1164
+ const serializedConfig = `${JSON.stringify(rawConfig, null, 2)}\n`;
1165
+ writeFileSync(join(outDir, PRIMARY_CONFIG_NAME), serializedConfig, "utf-8");
1166
+ writeFileSync(join(outDir, LEGACY_CONFIG_NAME), serializedConfig, "utf-8");
1167
+ console.log(`📋 Copied ${configName} as ${PRIMARY_CONFIG_NAME} (and legacy ${LEGACY_CONFIG_NAME})`);
1043
1168
 
1044
1169
  // ── 3b. Copy static assets from docs project into public/ ─────────────────
1045
1170
  copyStaticAssets(docsDir, join(outDir, "public"));
@@ -1054,12 +1179,13 @@ function build(docsDir: string, outDir: string) {
1054
1179
  const navLanguages = config.navigation.languages;
1055
1180
  const simpleLanguages = config.languages || [];
1056
1181
 
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);
1182
+ function processPage(srcPath: string, destPath: string, slug: string) {
1183
+ mkdirSync(dirname(destPath), { recursive: true });
1184
+ let content = readFileSync(srcPath, "utf-8");
1185
+ content = replaceVariablesInString(content, variables, relative(docsDir, srcPath), true);
1186
+ if (!content.startsWith("---")) {
1187
+ const titleMatch = content.match(/^#\s+(.+)$/m);
1188
+ const title = titleMatch ? titleMatch[1] : pageLabelFromSlug(slug);
1063
1189
  if (titleMatch) {
1064
1190
  content = content.replace(/^#\s+.+$/m, "").trimStart();
1065
1191
  }
@@ -1069,12 +1195,12 @@ function build(docsDir: string, outDir: string) {
1069
1195
  writeFileSync(destPath, content, "utf-8");
1070
1196
  }
1071
1197
 
1072
- function writeLangContent(
1073
- langCode: string,
1074
- artifacts: BuildArtifacts,
1075
- isDefault: boolean,
1076
- useLangFolders = false
1077
- ) {
1198
+ function writeLangContent(
1199
+ langCode: string,
1200
+ artifacts: BuildArtifacts,
1201
+ isDefault: boolean,
1202
+ useLangFolders = false
1203
+ ) {
1078
1204
  const storagePrefix = useLangFolders ? langCode : (isDefault ? "" : langCode);
1079
1205
  const urlPrefix = isDefault ? "" : langCode;
1080
1206
 
@@ -1082,53 +1208,55 @@ function build(docsDir: string, outDir: string) {
1082
1208
  const metas = storagePrefix
1083
1209
  ? artifacts.metaFiles.map((m) => ({ dir: m.dir ? `${storagePrefix}/${m.dir}` : storagePrefix, data: { ...m.data } }))
1084
1210
  : 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
- }
1090
-
1091
- function sanitizeFrontmatterValue(value: string): string {
1092
- return value.replace(/\r?\n+/g, " ").replace(/"/g, '\\"').trim();
1093
- }
1094
-
1095
- // Copy pages using explicit source paths from docs.json/velu.json
1096
- for (const mapping of artifacts.pageMap) {
1211
+ for (const meta of metas) {
1212
+ const metaPath = join(contentDir, meta.dir, "meta.json");
1213
+ mkdirSync(dirname(metaPath), { recursive: true });
1214
+ writeFileSync(metaPath, JSON.stringify(meta.data, null, 2) + "\n", "utf-8");
1215
+ }
1216
+
1217
+ function sanitizeFrontmatterValue(value: string): string {
1218
+ return value.replace(/\r?\n+/g, " ").replace(/"/g, '\\"').trim();
1219
+ }
1220
+
1221
+ // Copy pages using explicit source paths from docs.json/velu.json
1222
+ for (const mapping of artifacts.pageMap) {
1097
1223
  const destPath = join(
1098
1224
  contentDir,
1099
1225
  storagePrefix ? `${storagePrefix}/${mapping.dest}.mdx` : `${mapping.dest}.mdx`,
1100
1226
  );
1101
1227
 
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"` : "";
1228
+ if (mapping.kind === "openapi-operation") {
1229
+ mkdirSync(dirname(destPath), { recursive: true });
1230
+ const operationLabel = `${mapping.openapiMethod ?? "GET"} ${mapping.openapiEndpoint ?? "/"}`;
1231
+ const normalizedSpec = normalizeOpenApiSpecForFrontmatter(mapping.openapiSpec);
1232
+ const openapiValue = normalizedSpec
1233
+ ? `${normalizedSpec} ${operationLabel}`
1234
+ : operationLabel;
1235
+ const title = sanitizeFrontmatterValue(mapping.title ?? operationLabel);
1236
+ const description = typeof mapping.description === "string"
1237
+ ? sanitizeFrontmatterValue(mapping.description)
1238
+ : "";
1239
+ const version = typeof mapping.version === "string"
1240
+ ? sanitizeFrontmatterValue(mapping.version)
1241
+ : "";
1242
+ const openapi = openapiValue.replace(/"/g, '\\"');
1243
+ const warning = normalizedSpec
1244
+ ? ""
1245
+ : "\n> Warning: No OpenAPI spec source was resolved for this operation. Set `openapi` on this tab/group/navigation or at the top level.\n";
1246
+ const descriptionLine = description ? `\ndescription: "${description}"` : "";
1247
+ const deprecatedLine = mapping.deprecated === true ? `\ndeprecated: true` : "";
1248
+ const statusLine = mapping.deprecated === true ? `\nstatus: "deprecated"` : "";
1123
1249
  const versionLine = version ? `\nversion: "${version}"` : "";
1124
- const content = typeof mapping.content === "string" ? `${mapping.content.trim()}\n` : "";
1250
+ const content = typeof mapping.content === "string"
1251
+ ? `${replaceVariablesInString(mapping.content.trim(), variables, `openapi:${mapping.dest}`, true)}\n`
1252
+ : "";
1125
1253
  writeFileSync(
1126
- destPath,
1127
- `---\ntitle: "${title}"${descriptionLine}${deprecatedLine}${statusLine}${versionLine}\nopenapi: "${openapi}"\n---\n${warning}${content}`,
1128
- "utf-8",
1129
- );
1130
- continue;
1131
- }
1254
+ destPath,
1255
+ `---\ntitle: "${title}"${descriptionLine}${deprecatedLine}${statusLine}${versionLine}\nopenapi: "${openapi}"\n---\n${warning}${content}`,
1256
+ "utf-8",
1257
+ );
1258
+ continue;
1259
+ }
1132
1260
 
1133
1261
  const src = mapping.src;
1134
1262
  // Check for .mdx first, then .md
@@ -1138,10 +1266,10 @@ function build(docsDir: string, outDir: string) {
1138
1266
  srcPath = join(docsDir, `${src}.md`);
1139
1267
  ext = ".md";
1140
1268
  }
1141
- if (!existsSync(srcPath)) {
1142
- console.warn(`Warning: Missing page source: ${src}${ext} (language: ${langCode})`);
1143
- continue;
1144
- }
1269
+ if (!existsSync(srcPath)) {
1270
+ console.warn(`Warning: Missing page source: ${src}${ext} (language: ${langCode})`);
1271
+ continue;
1272
+ }
1145
1273
  processPage(srcPath, destPath, src);
1146
1274
  }
1147
1275
 
@@ -1166,7 +1294,7 @@ function build(docsDir: string, outDir: string) {
1166
1294
  const langEntry = navLanguages[i];
1167
1295
  const isDefault = i === 0;
1168
1296
  const langConfig = { ...config, navigation: { ...config.navigation, tabs: langEntry.tabs } } as VeluConfig;
1169
- const artifacts = buildArtifacts(langConfig, docsDir);
1297
+ const artifacts = buildArtifacts(langConfig, docsDir);
1170
1298
  writeLangContent(langEntry.language, artifacts, isDefault, true);
1171
1299
  totalPages += artifacts.pageMap.length;
1172
1300
  totalMeta += artifacts.metaFiles.length;
@@ -1177,7 +1305,7 @@ function build(docsDir: string, outDir: string) {
1177
1305
  writeFileSync(rootMetaPath, JSON.stringify({ pages: rootPages }, null, 2) + "\n", "utf-8");
1178
1306
  } else {
1179
1307
  // ── Mode 2: Simple (single-lang or same-nav multi-lang) ───────────
1180
- const artifacts = buildArtifacts(config, docsDir);
1308
+ const artifacts = buildArtifacts(config, docsDir);
1181
1309
  const useLangFolders = simpleLanguages.length > 1;
1182
1310
  writeLangContent(simpleLanguages[0] || "en", artifacts, true, useLangFolders);
1183
1311
  totalPages += artifacts.pageMap.length;