radiant-docs 0.1.0

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 (61) hide show
  1. package/dist/index.js +312 -0
  2. package/package.json +38 -0
  3. package/template/.vscode/extensions.json +4 -0
  4. package/template/.vscode/launch.json +11 -0
  5. package/template/astro.config.mjs +216 -0
  6. package/template/ec.config.mjs +51 -0
  7. package/template/package-lock.json +12546 -0
  8. package/template/package.json +51 -0
  9. package/template/public/favicon.svg +9 -0
  10. package/template/src/assets/icons/check.svg +33 -0
  11. package/template/src/assets/icons/danger.svg +37 -0
  12. package/template/src/assets/icons/info.svg +36 -0
  13. package/template/src/assets/icons/lightbulb.svg +74 -0
  14. package/template/src/assets/icons/warning.svg +37 -0
  15. package/template/src/components/Header.astro +176 -0
  16. package/template/src/components/MdxPage.astro +49 -0
  17. package/template/src/components/OpenApiPage.astro +270 -0
  18. package/template/src/components/Search.astro +362 -0
  19. package/template/src/components/Sidebar.astro +19 -0
  20. package/template/src/components/SidebarDropdown.astro +149 -0
  21. package/template/src/components/SidebarGroup.astro +51 -0
  22. package/template/src/components/SidebarLink.astro +56 -0
  23. package/template/src/components/SidebarMenu.astro +46 -0
  24. package/template/src/components/SidebarSubgroup.astro +136 -0
  25. package/template/src/components/TableOfContents.astro +480 -0
  26. package/template/src/components/ThemeSwitcher.astro +84 -0
  27. package/template/src/components/endpoint/PlaygroundBar.astro +68 -0
  28. package/template/src/components/endpoint/PlaygroundButton.astro +44 -0
  29. package/template/src/components/endpoint/PlaygroundField.astro +54 -0
  30. package/template/src/components/endpoint/PlaygroundForm.astro +203 -0
  31. package/template/src/components/endpoint/RequestSnippets.astro +308 -0
  32. package/template/src/components/endpoint/ResponseDisplay.astro +177 -0
  33. package/template/src/components/endpoint/ResponseFields.astro +224 -0
  34. package/template/src/components/endpoint/ResponseSnippets.astro +247 -0
  35. package/template/src/components/sidebar/SidebarEndpointLink.astro +51 -0
  36. package/template/src/components/sidebar/SidebarOpenApi.astro +207 -0
  37. package/template/src/components/ui/Field.astro +69 -0
  38. package/template/src/components/ui/Tag.astro +5 -0
  39. package/template/src/components/ui/demo/CodeDemo.astro +15 -0
  40. package/template/src/components/ui/demo/Demo.astro +3 -0
  41. package/template/src/components/ui/demo/UiDisplay.astro +13 -0
  42. package/template/src/components/user/Accordian.astro +69 -0
  43. package/template/src/components/user/AccordianGroup.astro +13 -0
  44. package/template/src/components/user/Callout.astro +101 -0
  45. package/template/src/components/user/Step.astro +51 -0
  46. package/template/src/components/user/Steps.astro +9 -0
  47. package/template/src/components/user/Tab.astro +25 -0
  48. package/template/src/components/user/Tabs.astro +122 -0
  49. package/template/src/content.config.ts +11 -0
  50. package/template/src/entrypoint.ts +9 -0
  51. package/template/src/layouts/Layout.astro +92 -0
  52. package/template/src/lib/component-error.ts +163 -0
  53. package/template/src/lib/frontmatter-schema.ts +9 -0
  54. package/template/src/lib/oas.ts +24 -0
  55. package/template/src/lib/pagefind.ts +88 -0
  56. package/template/src/lib/routes.ts +316 -0
  57. package/template/src/lib/utils.ts +59 -0
  58. package/template/src/lib/validation.ts +1097 -0
  59. package/template/src/pages/[...slug].astro +77 -0
  60. package/template/src/styles/global.css +209 -0
  61. package/template/tsconfig.json +5 -0
@@ -0,0 +1,1097 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import pkg from "@stoplight/spectral-core";
4
+ const { Spectral } = pkg;
5
+ import { oas } from "@stoplight/spectral-rulesets";
6
+ import { compile } from "@mdx-js/mdx";
7
+ import yaml from "yaml";
8
+ import { docsSchema } from "./frontmatter-schema";
9
+
10
+ // --- Configuration Constants ---
11
+ const CWD = process.cwd();
12
+ const DOCS_DIR = path.join(CWD, "src/content/docs");
13
+ const CONFIG_PATH = path.join(DOCS_DIR, "docs.json");
14
+
15
+ // Define the list of available user components for MDX
16
+ const AVAILABLE_COMPONENTS = [
17
+ "Callout",
18
+ "Tabs",
19
+ "Tab",
20
+ "Steps",
21
+ "Step",
22
+ "Accordian",
23
+ "AccordianGroup",
24
+ ];
25
+
26
+ export type NavPage = { page: string; icon?: string; tag?: string };
27
+ export type NavGroup = {
28
+ group: string;
29
+ pages: (string | NavPage | NavGroup)[];
30
+ icon?: string;
31
+ expanded?: boolean; // need to add this logic
32
+ tag?: string;
33
+ };
34
+ export type NavOpenApi = {
35
+ source: string;
36
+ include?: string[];
37
+ exclude?: string[];
38
+ };
39
+ export type NavigationItem = {
40
+ pages?: (string | NavPage)[];
41
+ groups?: NavGroup[];
42
+ menu?: NavMenu;
43
+ openapi?: string | NavOpenApi;
44
+ };
45
+
46
+ export type NavMenuItem = {
47
+ label: string;
48
+ submenu: Omit<NavigationItem, "menu">;
49
+ icon?: string;
50
+ };
51
+ export type NavMenu = {
52
+ type?: "dropdown" | "collapsible";
53
+ label?: string;
54
+ items: NavMenuItem[];
55
+ };
56
+ export type NavbarItem = {
57
+ text: string;
58
+ href: string;
59
+ icon?: string;
60
+ };
61
+ export type Logo = {
62
+ light?: string;
63
+ dark?: string;
64
+ href?: string;
65
+ };
66
+ export type DocsConfig = {
67
+ title: string;
68
+ logo?: Logo;
69
+ home?: string;
70
+ navigation: NavigationItem;
71
+ navbar?: {
72
+ blur?: boolean;
73
+ primary?: NavbarItem;
74
+ secondary?: NavbarItem;
75
+ links?: NavbarItem[];
76
+ };
77
+ };
78
+ type Path = (string | number)[];
79
+
80
+ // --- 1. Error Utility ---
81
+
82
+ const throwConfigError = (message: string, currentPath: Path): never => {
83
+ const location =
84
+ currentPath.length > 0 ? ` (at: ${currentPath.join(".")})` : "";
85
+ throw new Error(`${message}${location}\n`);
86
+ };
87
+
88
+ // --- 2. Core Validation Logic (Recursive and Structural) ---
89
+
90
+ // Helper for basic type checks, allowing undefined for optional keys
91
+ function checkType(
92
+ value: any,
93
+ type: "string" | "boolean" | "array" | "object",
94
+ currentPath: Path,
95
+ label: string
96
+ ): void {
97
+ if (value === undefined) return;
98
+
99
+ if (type === "array") {
100
+ if (!Array.isArray(value))
101
+ throwConfigError(`${label} must be an array.`, currentPath);
102
+ } else if (type === "object") {
103
+ if (typeof value !== "object" || value === null)
104
+ throwConfigError(`${label} must be an object.`, currentPath);
105
+ } else {
106
+ if (typeof value !== type)
107
+ throwConfigError(`${label} must be a ${type}.`, currentPath);
108
+ }
109
+ }
110
+
111
+ function validateFileExistence(filePath: string, currentPath: Path): void {
112
+ // Assuming relative path from DOCS_DIR and .mdx extension
113
+ const fullPath = path.join(DOCS_DIR, `${filePath}.mdx`);
114
+
115
+ if (!fs.existsSync(fullPath)) {
116
+ throwConfigError(
117
+ `Referenced file not found. Expected: ${filePath}`,
118
+ currentPath
119
+ );
120
+ }
121
+ }
122
+
123
+ // Helper function to check if a string is a URL
124
+ function isUrl(str: string): boolean {
125
+ try {
126
+ const url = new URL(str);
127
+ return url.protocol === "http:" || url.protocol === "https:";
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ // Cache for OpenAPI specs (key: filePathOrUrl, value: parsed spec)
134
+ const openApiSpecCache = new Map<string, any>();
135
+
136
+ // Helper function to load and parse OpenAPI spec
137
+ export async function loadOpenApiSpec(filePathOrUrl: string): Promise<any> {
138
+ // Check cache first
139
+ if (openApiSpecCache.has(filePathOrUrl)) {
140
+ return openApiSpecCache.get(filePathOrUrl);
141
+ }
142
+
143
+ const isUrlPath = isUrl(filePathOrUrl);
144
+
145
+ let fileContent: string;
146
+
147
+ if (isUrlPath) {
148
+ // Fetch from URL
149
+ try {
150
+ const response = await fetch(filePathOrUrl);
151
+ if (!response.ok) {
152
+ throw new Error(
153
+ `Failed to fetch OpenAPI spec: ${response.status} ${response.statusText}`
154
+ );
155
+ }
156
+ fileContent = await response.text();
157
+ } catch (error) {
158
+ throw new Error(
159
+ `Failed to fetch OpenAPI spec from URL: ${
160
+ error instanceof Error ? error.message : String(error)
161
+ }`
162
+ );
163
+ }
164
+ } else {
165
+ // Read from local file
166
+ const fullPath = path.join(DOCS_DIR, filePathOrUrl);
167
+ fileContent = fs.readFileSync(fullPath, "utf-8");
168
+ }
169
+
170
+ // Detect HTML content (common mistake: URL returns HTML page instead of spec)
171
+ const trimmedContent = fileContent.trim();
172
+ if (
173
+ trimmedContent.startsWith("<!DOCTYPE") ||
174
+ trimmedContent.startsWith("<html")
175
+ ) {
176
+ throw new Error(
177
+ "The URL does not return a valid OpenAPI specification. The URL appears to return HTML instead of JSON or YAML."
178
+ );
179
+ }
180
+
181
+ // Determine format and parse
182
+ let parsedSpec: any;
183
+ try {
184
+ if (
185
+ filePathOrUrl.endsWith(".json") ||
186
+ (isUrlPath && filePathOrUrl.includes(".json"))
187
+ ) {
188
+ parsedSpec = JSON.parse(fileContent);
189
+ } else {
190
+ const yaml = await import("yaml");
191
+ parsedSpec = yaml.parse(fileContent);
192
+ }
193
+ } catch (parseError) {
194
+ if (parseError instanceof SyntaxError) {
195
+ throw new Error(
196
+ `The URL does not return a valid OpenAPI specification. Failed to parse as JSON or YAML: ${parseError.message}`
197
+ );
198
+ }
199
+ throw parseError;
200
+ }
201
+
202
+ // Cache the parsed spec
203
+ openApiSpecCache.set(filePathOrUrl, parsedSpec);
204
+
205
+ return parsedSpec;
206
+ }
207
+
208
+ async function validateOpenApiFile(filePathOrUrl: string, currentPath: Path) {
209
+ const isUrlPath = isUrl(filePathOrUrl);
210
+
211
+ if (!isUrlPath) {
212
+ // For local files, validate extension and existence
213
+ const validExtensions = [".json", ".yaml", ".yml"];
214
+ const hasValidExtension = validExtensions.some((ext) =>
215
+ filePathOrUrl.toLowerCase().endsWith(ext)
216
+ );
217
+
218
+ if (!hasValidExtension) {
219
+ throwConfigError(
220
+ `OpenAPI file must have a valid extension (.json, .yaml, or .yml). Found: ${filePathOrUrl}`,
221
+ currentPath
222
+ );
223
+ }
224
+
225
+ const fullPath = path.join(DOCS_DIR, filePathOrUrl);
226
+
227
+ if (!fs.existsSync(fullPath)) {
228
+ throwConfigError(
229
+ `Referenced OpenAPI file not found. Expected: ${filePathOrUrl}`,
230
+ currentPath
231
+ );
232
+ }
233
+ } else {
234
+ // For URLs, validate that it's a valid HTTP/HTTPS URL
235
+ try {
236
+ const url = new URL(filePathOrUrl);
237
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
238
+ throwConfigError(
239
+ `OpenAPI URL must use http:// or https:// protocol. Found: ${filePathOrUrl}`,
240
+ currentPath
241
+ );
242
+ }
243
+ } catch (error) {
244
+ throwConfigError(
245
+ `Invalid OpenAPI URL format: ${filePathOrUrl}`,
246
+ currentPath
247
+ );
248
+ }
249
+ }
250
+
251
+ // Validate the OpenAPI spec using Spectral (works for both files and URLs)
252
+ try {
253
+ const document = await loadOpenApiSpec(filePathOrUrl);
254
+
255
+ const basicRuleset = {
256
+ formats: oas.formats,
257
+ rules: {
258
+ "oas3-schema": oas.rules["oas3-schema"],
259
+ },
260
+ };
261
+
262
+ const spectral = new Spectral();
263
+
264
+ spectral.setRuleset(basicRuleset);
265
+
266
+ let results = await spectral.run(document);
267
+
268
+ if (results.length > 0) {
269
+ // Format validation errors - show first few errors
270
+ const errorMessages = results
271
+ .slice(0, 5) // Limit to first 5 errors
272
+ .map((result) => {
273
+ const pathStr =
274
+ result.path.length > 0 ? result.path.join(".") : "root";
275
+ return `${result.message} (at ${pathStr})`;
276
+ });
277
+
278
+ const errorText = errorMessages.join("; ");
279
+ const moreErrors =
280
+ results.length > 5 ? ` (and ${results.length - 5} more errors)` : "";
281
+
282
+ throwConfigError(
283
+ `Invalid OpenAPI specification: ${errorText}${moreErrors}`,
284
+ currentPath
285
+ );
286
+ }
287
+ } catch (error) {
288
+ // Handle parsing errors separately from validation errors
289
+ if (error instanceof SyntaxError) {
290
+ throwConfigError(
291
+ `Failed to parse OpenAPI file: ${error.message}`,
292
+ currentPath
293
+ );
294
+ } else if (error instanceof Error) {
295
+ throwConfigError(
296
+ `Invalid OpenAPI specification: ${error.message}`,
297
+ currentPath
298
+ );
299
+ } else {
300
+ throwConfigError(
301
+ `Invalid OpenAPI specification: ${String(error)}`,
302
+ currentPath
303
+ );
304
+ }
305
+ }
306
+ }
307
+
308
+ function extractAvailableEndpoints(openApiDoc: any): Set<string> {
309
+ const endpoints = new Set<string>();
310
+ const paths = openApiDoc.paths || {};
311
+ const httpMethods = [
312
+ "get",
313
+ "post",
314
+ "put",
315
+ "delete",
316
+ "patch",
317
+ "head",
318
+ "options",
319
+ "trace",
320
+ ];
321
+
322
+ for (const [pathStr, pathItem] of Object.entries(paths)) {
323
+ if (!pathItem || typeof pathItem !== "object") continue;
324
+
325
+ for (const method of httpMethods) {
326
+ const operation = (pathItem as any)[method];
327
+ if (operation) {
328
+ // Store as "METHOD /path" (uppercase method, lowercase path)
329
+ const normalizedMethod = method.toUpperCase();
330
+ const normalizedPath = pathStr.toLowerCase();
331
+ endpoints.add(`${normalizedMethod} ${normalizedPath}`);
332
+ }
333
+ }
334
+ }
335
+
336
+ return endpoints;
337
+ }
338
+
339
+ // Helper function to parse endpoint string (e.g., "get /burgers" or "POST /burgers")
340
+ function parseEndpointString(
341
+ endpointStr: string
342
+ ): { method: string; path: string } | null {
343
+ const trimmed = endpointStr.trim();
344
+ const parts = trimmed.split(/\s+/);
345
+
346
+ if (parts.length !== 2) {
347
+ return null;
348
+ }
349
+
350
+ const method = parts[0].toUpperCase();
351
+ let path = parts[1];
352
+
353
+ // Ensure path starts with /
354
+ if (!path.startsWith("/")) {
355
+ path = "/" + path;
356
+ }
357
+
358
+ // Normalize path to lowercase for comparison
359
+ const normalizedPath = path.toLowerCase();
360
+
361
+ return { method, path: normalizedPath };
362
+ }
363
+
364
+ function validateNavigationNode(item: any, currentPath: Path): void {
365
+ // A) Base Case: Simple string path
366
+ if (typeof item === "string") {
367
+ validateFileExistence(item, currentPath);
368
+ return;
369
+ }
370
+
371
+ // B) Must be an object
372
+ checkType(item, "object", currentPath, "Navigation item");
373
+
374
+ // Determine item type by key presence (Strict XOR enforcement)
375
+ const isGroup = "group" in item;
376
+ const isPage = "page" in item;
377
+
378
+ const typeCount = [isGroup, isPage].filter(Boolean).length;
379
+ if (typeCount !== 1) {
380
+ throwConfigError(
381
+ "Object must contain exactly one key: 'page' or 'group'.",
382
+ currentPath
383
+ );
384
+ }
385
+
386
+ // --- Validate Group (Recursive) ---
387
+ if (isGroup) {
388
+ const path = [...currentPath];
389
+ checkType(item.group, "string", [...path, "group"], "Group name");
390
+
391
+ // C.2: THE EXPANDED CHECK (Kept clean)
392
+ checkType(item.expanded, "boolean", [...path, "expanded"], "Expanded");
393
+
394
+ // Check if pages array exists and validate children
395
+ if (!item.pages)
396
+ throwConfigError("Group must have a 'pages' array.", [...path, "pages"]);
397
+ checkType(item.pages, "array", [...path, "pages"], "Group pages");
398
+
399
+ item.pages.forEach((child: any, i: number) => {
400
+ validateNavigationNode(child, [...path, "pages", i]); // Recursive call
401
+ });
402
+ return;
403
+ }
404
+
405
+ // --- Validate Page ---
406
+ if (isPage) {
407
+ const path = [...currentPath];
408
+ checkType(item.page, "string", [...path, "page"], "Page path");
409
+
410
+ validateFileExistence(item.page, [...path, "page"]);
411
+
412
+ // Check D.2/D.3: Page cannot have group properties
413
+ if ("expanded" in item)
414
+ throwConfigError("Page items cannot have 'expanded'.", [
415
+ ...path,
416
+ "expanded",
417
+ ]);
418
+ if ("pages" in item)
419
+ throwConfigError("Page items cannot have children.", [...path, "pages"]);
420
+ return;
421
+ }
422
+ }
423
+
424
+ async function validateNavOpenApi(
425
+ navOpenApi: any,
426
+ currentPath: Path
427
+ ): Promise<void> {
428
+ checkType(navOpenApi, "object", currentPath, "Open API object");
429
+
430
+ // Required: source (must be a string)
431
+ if (typeof navOpenApi.source !== "string") {
432
+ throwConfigError(
433
+ "Open API object must have an 'source' property that is a string.",
434
+ [...currentPath, "source"]
435
+ );
436
+ }
437
+
438
+ // Validate the OpenAPI file exists and is valid
439
+ await validateOpenApiFile(navOpenApi.source, [...currentPath, "source"]);
440
+
441
+ // Check mutual exclusivity of include and exclude
442
+ const hasInclude = "include" in navOpenApi;
443
+ const hasExclude = "exclude" in navOpenApi;
444
+
445
+ if (hasInclude && hasExclude) {
446
+ throwConfigError(
447
+ "Open API object cannot have both 'include' and 'exclude' properties. They are mutually exclusive.",
448
+ currentPath
449
+ );
450
+ }
451
+
452
+ // If neither include nor exclude is present, that's valid (all endpoints will be included)
453
+ if (!hasInclude && !hasExclude) {
454
+ return;
455
+ }
456
+
457
+ // Load the OpenAPI spec to validate against
458
+ const openApiDoc = await loadOpenApiSpec(navOpenApi.source);
459
+ const availableEndpoints = extractAvailableEndpoints(openApiDoc);
460
+
461
+ // Validate include array
462
+ if (hasInclude) {
463
+ checkType(
464
+ navOpenApi.include,
465
+ "array",
466
+ [...currentPath, "include"],
467
+ "Include array"
468
+ );
469
+
470
+ if (navOpenApi.include.length === 0) {
471
+ throwConfigError("Include array cannot be empty.", [
472
+ ...currentPath,
473
+ "include",
474
+ ]);
475
+ }
476
+
477
+ // Validate each entry
478
+ for (const [i, entry] of navOpenApi.include.entries()) {
479
+ if (typeof entry !== "string") {
480
+ throwConfigError(
481
+ `Include entry at index ${i} must be a string in the format "METHOD /path".`,
482
+ [...currentPath, "include", i]
483
+ );
484
+ }
485
+
486
+ const parsed = parseEndpointString(entry);
487
+ if (!parsed) {
488
+ throwConfigError(
489
+ `Include entry at index ${i} must be in the format "METHOD /path". Found: ${entry}`,
490
+ [...currentPath, "include", i]
491
+ );
492
+ }
493
+
494
+ // Check if endpoint exists in the OpenAPI spec
495
+ const endpointKey = `${parsed?.method} ${parsed?.path}`;
496
+ if (!availableEndpoints.has(endpointKey)) {
497
+ throwConfigError(
498
+ `Include entry at index ${i} does not match any endpoint in the OpenAPI spec. Found: ${entry}. Expected format: "METHOD /path".`,
499
+ [...currentPath, "include", i]
500
+ );
501
+ }
502
+ }
503
+ }
504
+
505
+ // Validate exclude array
506
+ if (hasExclude) {
507
+ checkType(
508
+ navOpenApi.exclude,
509
+ "array",
510
+ [...currentPath, "exclude"],
511
+ "Exclude array"
512
+ );
513
+
514
+ if (navOpenApi.exclude.length === 0) {
515
+ throwConfigError("Exclude array cannot be empty.", [
516
+ ...currentPath,
517
+ "exclude",
518
+ ]);
519
+ }
520
+
521
+ // Validate each entry
522
+ for (const [i, entry] of navOpenApi.exclude.entries()) {
523
+ if (typeof entry !== "string") {
524
+ throwConfigError(
525
+ `Exclude entry at index ${i} must be a string in the format "METHOD /path".`,
526
+ [...currentPath, "exclude", i]
527
+ );
528
+ }
529
+
530
+ const parsed = parseEndpointString(entry);
531
+ if (!parsed) {
532
+ throwConfigError(
533
+ `Exclude entry at index ${i} must be in the format "METHOD /path" (e.g., "get /burgers"). Found: ${entry}`,
534
+ [...currentPath, "exclude", i]
535
+ );
536
+ }
537
+
538
+ // Check if endpoint exists in the OpenAPI spec
539
+ const endpointKey = `${parsed?.method} ${parsed?.path}`;
540
+ if (!availableEndpoints.has(endpointKey)) {
541
+ throwConfigError(
542
+ `Exclude entry at index ${i} does not match any endpoint in the OpenAPI spec. Found: ${entry}. Expected format: "METHOD /path" (case-insensitive).`,
543
+ [...currentPath, "exclude", i]
544
+ );
545
+ }
546
+ }
547
+ }
548
+ }
549
+
550
+ async function validateNavMenuItem(item: NavMenuItem, currentPath: Path) {
551
+ checkType(item, "object", currentPath, "Menu item");
552
+
553
+ checkType(item.icon, "string", [...currentPath, "icon"], "Menu item icon");
554
+
555
+ // Required: label
556
+ if (!item.label) {
557
+ throwConfigError("Menu item must have a 'label' property.", [
558
+ ...currentPath,
559
+ "label",
560
+ ]);
561
+ }
562
+ checkType(item.label, "string", [...currentPath, "label"], "Label");
563
+
564
+ // Required: submenu
565
+ if (!item.submenu) {
566
+ throwConfigError("Menu item must have a 'submenu' property.", [
567
+ ...currentPath,
568
+ "submenu",
569
+ ]);
570
+ }
571
+ checkType(item.submenu, "object", [...currentPath, "submenu"], "Submenu");
572
+
573
+ const submenu = item.submenu;
574
+ const submenuKeys = Object.keys(submenu);
575
+ const validSubmenuKeys = ["pages", "groups", "openapi"];
576
+ const presentSubmenuKeys = submenuKeys.filter((key) =>
577
+ validSubmenuKeys.includes(key)
578
+ );
579
+ const invalidSubmenuKeys = submenuKeys.filter(
580
+ (key) => !validSubmenuKeys.includes(key)
581
+ );
582
+
583
+ // Submenu must have exactly one key total
584
+ if (submenuKeys.length !== 1) {
585
+ if (submenuKeys.length === 0) {
586
+ throwConfigError(
587
+ `Submenu must contain exactly one key (${validSubmenuKeys.join(
588
+ ", "
589
+ )}). Found no keys.`,
590
+ [...currentPath, "submenu"]
591
+ );
592
+ } else {
593
+ throwConfigError(
594
+ `Submenu must contain exactly one key (${validSubmenuKeys.join(
595
+ ", "
596
+ )}). Found ${submenuKeys.length} key(s): ${submenuKeys.join(", ")}.`,
597
+ [...currentPath, "submenu"]
598
+ );
599
+ }
600
+ }
601
+
602
+ // Check if the single key is valid
603
+ if (presentSubmenuKeys.length !== 1) {
604
+ const invalidKey = invalidSubmenuKeys[0];
605
+ throwConfigError(
606
+ `Submenu must contain exactly one key (${validSubmenuKeys.join(
607
+ ", "
608
+ )}). Found invalid key: ${invalidKey}.`,
609
+ [...currentPath, "submenu"]
610
+ );
611
+ }
612
+
613
+ const submenuKey = presentSubmenuKeys[0];
614
+ const submenuValue =
615
+ submenu[submenuKey as keyof Omit<NavigationItem, "menu">];
616
+
617
+ // Validate pages array
618
+ if (submenuKey === "pages") {
619
+ checkType(
620
+ submenuValue,
621
+ "array",
622
+ [...currentPath, "submenu", "pages"],
623
+ "Submenu pages"
624
+ );
625
+ (submenuValue as NavigationItem["pages"])?.forEach(
626
+ (page: string | NavPage, i: number) => {
627
+ if (typeof page === "string") {
628
+ validateFileExistence(page, [...currentPath, "submenu", "pages", i]);
629
+ } else {
630
+ validateNavigationNode(page, [...currentPath, "submenu", "pages", i]);
631
+ }
632
+ }
633
+ );
634
+ }
635
+
636
+ // Validate groups array
637
+ if (submenuKey === "groups") {
638
+ checkType(
639
+ submenuValue,
640
+ "array",
641
+ [...currentPath, "submenu", "groups"],
642
+ "Submenu groups"
643
+ );
644
+ (submenuValue as NavigationItem["groups"])?.forEach(
645
+ (group: NavGroup, i: number) => {
646
+ validateNavigationNode(group, [...currentPath, "submenu", "groups", i]);
647
+ }
648
+ );
649
+ }
650
+
651
+ // Validate openapi - can be string or NavOpenApi object
652
+ if (submenuKey === "openapi") {
653
+ if (typeof submenuValue === "string") {
654
+ // Simple string case - validate file exists and is valid
655
+ await validateOpenApiFile(submenuValue, [
656
+ ...currentPath,
657
+ "submenu",
658
+ "openapi",
659
+ ]);
660
+ } else if (typeof submenuValue === "object") {
661
+ // NavOpenApi object case - validate structure and include/exclude
662
+ await validateNavOpenApi(submenuValue, [
663
+ ...currentPath,
664
+ "submenu",
665
+ "openapi",
666
+ ]);
667
+ } else {
668
+ throwConfigError(
669
+ "OpenAPI must be either a string (file path or hosted file) or an object.",
670
+ [...currentPath, "submenu", "openapi"]
671
+ );
672
+ }
673
+ }
674
+ }
675
+
676
+ async function validateNavMenu(menu: any, currentPath: Path) {
677
+ checkType(menu, "object", currentPath, "Menu");
678
+
679
+ // Optional: type
680
+ if (menu.type !== undefined) {
681
+ if (menu.type !== "dropdown" && menu.type !== "collapsible") {
682
+ throwConfigError(
683
+ "Menu type must be 'dropdown' or 'collapsible' if provided. Defaults to 'dropdown'",
684
+ [...currentPath, "type"]
685
+ );
686
+ }
687
+ }
688
+
689
+ // Optional: label
690
+ checkType(menu.label, "string", [...currentPath, "label"], "Menu label");
691
+
692
+ // Required: items
693
+ if (!menu.items) {
694
+ throwConfigError("Menu must have an 'items' array.", [
695
+ ...currentPath,
696
+ "items",
697
+ ]);
698
+ }
699
+ checkType(menu.items, "array", [...currentPath, "items"], "Menu items");
700
+
701
+ // Validate each menu item
702
+ for (const [i, item] of menu.items.entries()) {
703
+ await validateNavMenuItem(item, [...currentPath, "items", i]);
704
+ }
705
+ }
706
+
707
+ function validateNavbarItem(item: any, currentPath: Path): void {
708
+ // Check if object exists, otherwise we skip (it's optional)
709
+ if (item === undefined) return;
710
+
711
+ checkType(item, "object", currentPath, "Navbar item");
712
+
713
+ // Required properties
714
+ if (typeof item.text !== "string") {
715
+ throwConfigError("Navbar item must have a 'text' property.", [
716
+ ...currentPath,
717
+ "text",
718
+ ]);
719
+ }
720
+ if (typeof item.href !== "string") {
721
+ throwConfigError("Navbar item must have an 'href' property.", [
722
+ ...currentPath,
723
+ "href",
724
+ ]);
725
+ }
726
+
727
+ // Optional property
728
+ checkType(item.icon, "string", [...currentPath, "icon"], "Navbar icon");
729
+ }
730
+
731
+ // --- Top-Level Validation Functions (Your Clean API) ---
732
+
733
+ function validateTitle(title: DocsConfig["title"]) {
734
+ checkType(title, "string", ["title"], "Title");
735
+ if (!title) throwConfigError("Title is missing.", ["title"]);
736
+ }
737
+
738
+ function validateLogo(logo: DocsConfig["logo"]) {
739
+ // Logo is optional, so if it's undefined, we're done
740
+ if (logo === undefined) return;
741
+
742
+ // If logo is provided, it must be an object
743
+ checkType(logo, "object", ["logo"], "Logo configuration");
744
+
745
+ // Validate 'light' logo if provided
746
+ if (logo.light !== undefined) {
747
+ checkType(logo.light, "string", ["logo", "light"], "Logo light path");
748
+
749
+ // Validate file extension
750
+ const validExtensions = [".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif"];
751
+ const hasValidExtension = validExtensions.some((ext) =>
752
+ logo.light!.toLowerCase().endsWith(ext)
753
+ );
754
+ if (!hasValidExtension) {
755
+ throwConfigError(
756
+ "Logo light must be a valid image file (.svg, .png, .jpg, .jpeg, .webp, .gif)",
757
+ ["logo", "light"]
758
+ );
759
+ }
760
+
761
+ // Validate file exists in content/docs folder
762
+ // Normalize path: remove leading slash if present
763
+ const normalizedPath = logo.light.startsWith("/")
764
+ ? logo.light.slice(1)
765
+ : logo.light;
766
+ const fullPath = path.join(DOCS_DIR, normalizedPath);
767
+
768
+ if (!fs.existsSync(fullPath)) {
769
+ throwConfigError(
770
+ `Logo light file not found. Expected: ${normalizedPath} (relative to content/docs folder)`,
771
+ ["logo", "light"]
772
+ );
773
+ }
774
+ }
775
+
776
+ // Validate 'dark' logo if provided
777
+ if (logo.dark !== undefined) {
778
+ checkType(logo.dark, "string", ["logo", "dark"], "Logo dark path");
779
+
780
+ // Validate file extension
781
+ const validExtensions = [".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif"];
782
+ const hasValidExtension = validExtensions.some((ext) =>
783
+ logo.dark!.toLowerCase().endsWith(ext)
784
+ );
785
+ if (!hasValidExtension) {
786
+ throwConfigError(
787
+ "Logo dark must be a valid image file (.svg, .png, .jpg, .jpeg, .webp, .gif)",
788
+ ["logo", "dark"]
789
+ );
790
+ }
791
+
792
+ // Validate file exists in content/docs folder
793
+ // Normalize path: remove leading slash if present
794
+ const normalizedPath = logo.dark.startsWith("/")
795
+ ? logo.dark.slice(1)
796
+ : logo.dark;
797
+ const fullPath = path.join(DOCS_DIR, normalizedPath);
798
+
799
+ if (!fs.existsSync(fullPath)) {
800
+ throwConfigError(
801
+ `Logo dark file not found. Expected: ${normalizedPath} (relative to content/docs folder)`,
802
+ ["logo", "dark"]
803
+ );
804
+ }
805
+ }
806
+
807
+ // Validate 'href' if provided
808
+ if (logo.href !== undefined) {
809
+ checkType(logo.href, "string", ["logo", "href"], "Logo href");
810
+
811
+ // Validate it's either a valid URL or a valid internal path
812
+ // Internal paths should start with /
813
+ // External URLs should start with http:// or https://
814
+ const trimmedHref = logo.href.trim();
815
+
816
+ if (trimmedHref === "") {
817
+ throwConfigError("Logo href cannot be an empty string", ["logo", "href"]);
818
+ }
819
+
820
+ // Check if it's a URL
821
+ const isUrl =
822
+ trimmedHref.startsWith("http://") || trimmedHref.startsWith("https://");
823
+
824
+ // Check if it's an internal path (starts with /)
825
+ const isInternalPath = trimmedHref.startsWith("/");
826
+
827
+ if (!isUrl && !isInternalPath) {
828
+ throwConfigError(
829
+ "Logo href must be either a valid URL (http:// or https://) or an internal path (starting with /)",
830
+ ["logo", "href"]
831
+ );
832
+ }
833
+ }
834
+ }
835
+
836
+ function validateHome(home: DocsConfig["home"]) {
837
+ checkType(home, "string", ["home"], "Home path");
838
+ }
839
+
840
+ function validateNavbar(navbar: DocsConfig["navbar"]) {
841
+ if (navbar === undefined) return; // Navbar itself is optional
842
+
843
+ checkType(navbar, "object", ["navbar"], "Navbar configuration");
844
+
845
+ // Validate 'blur'
846
+ checkType(navbar.blur, "boolean", ["navbar", "blur"], "Navbar blur setting");
847
+
848
+ // Validate 'primary' item
849
+ validateNavbarItem(navbar.primary, ["navbar", "primary"]);
850
+
851
+ // Validate 'secondary' item
852
+ validateNavbarItem(navbar.secondary, ["navbar", "secondary"]);
853
+
854
+ // Validate 'links' array
855
+ if (navbar.links !== undefined) {
856
+ checkType(navbar.links, "array", ["navbar", "links"], "Navbar links");
857
+
858
+ if (navbar.links.length > 3) {
859
+ throwConfigError("Navbar links cannot have more than 3 items.", [
860
+ "navbar",
861
+ "links",
862
+ ]);
863
+ }
864
+
865
+ navbar.links.forEach((link: any, i: number) => {
866
+ validateNavbarItem(link, ["navbar", "links", i]);
867
+ });
868
+ }
869
+ }
870
+
871
+ async function validateNavigation(navigation: DocsConfig["navigation"]) {
872
+ checkType(navigation, "object", ["navigation"], "Navigation");
873
+
874
+ const keys = Object.keys(navigation);
875
+ const validKeys = ["pages", "groups", "menu", "openapi"];
876
+ const navKeys = keys.filter((key) => validKeys.includes(key));
877
+
878
+ if (navKeys.length !== 1) {
879
+ throwConfigError(
880
+ `Navigation must contain exactly one top-level item (${validKeys.join(
881
+ ", "
882
+ )}). Found ${navKeys.length}.`,
883
+ ["navigation"]
884
+ );
885
+ }
886
+
887
+ const navKey = navKeys[0];
888
+ const navValue = (navigation as any)[navKey];
889
+
890
+ // Handle "menu" as an object, "pages" and "groups" as arrays
891
+ if (navKey === "menu") {
892
+ await validateNavMenu(navValue, ["navigation", "menu"]);
893
+ } else {
894
+ // Validate the container itself is an array for pages/groups
895
+ checkType(
896
+ navValue,
897
+ "array",
898
+ ["navigation", navKey],
899
+ `Navigation container '${navKey}'`
900
+ );
901
+
902
+ // Route to Recursive Structural Validation
903
+ navValue.forEach((item: NavigationItem, i: number) => {
904
+ validateNavigationNode(item, ["navigation", navKey, i]);
905
+ });
906
+ }
907
+ }
908
+
909
+ // --- Config Runner ---
910
+
911
+ async function validateConfig(config: any): Promise<DocsConfig> {
912
+ // Execute top-level checks sequentially
913
+ validateTitle(config.title);
914
+ validateLogo(config.logo);
915
+ validateHome(config.home);
916
+ validateNavbar(config.navbar);
917
+ await validateNavigation(config.navigation);
918
+
919
+ return config as DocsConfig;
920
+ }
921
+
922
+ let configCache: Promise<DocsConfig> | null = null;
923
+ let lastMtime: number = 0;
924
+
925
+ export async function getConfig(): Promise<DocsConfig> {
926
+ // 1. Check if docs.json exists
927
+ if (!fs.existsSync(CONFIG_PATH)) {
928
+ throw new Error(
929
+ "[USER_ERROR]: Invalid docs.json: docs.json missing at root of documentation repo."
930
+ );
931
+ }
932
+
933
+ // 2. Check if docs.json has changed
934
+ const stats = fs.statSync(CONFIG_PATH);
935
+ if (configCache && stats.mtimeMs === lastMtime) {
936
+ return configCache;
937
+ }
938
+
939
+ // 3. If docs.json changed or first run, update cache
940
+ lastMtime = stats.mtimeMs;
941
+ configCache = (async () => {
942
+ const fileContent = fs.readFileSync(CONFIG_PATH, "utf-8");
943
+ let config: any;
944
+ try {
945
+ config = JSON.parse(fileContent);
946
+ } catch (e) {
947
+ throw new Error(
948
+ `[USER_ERROR]: Invalid docs.json: Invalid JSON syntax: ${
949
+ e instanceof Error ? e.message : e
950
+ }`
951
+ );
952
+ }
953
+ // ---
954
+
955
+ // The custom validation is executed here
956
+ try {
957
+ const validatedConfig = await validateConfig(config);
958
+ return validatedConfig;
959
+ } catch (error) {
960
+ // Catch the custom error thrown by throwConfigError and re-throw it.
961
+ throw new Error(
962
+ `[USER_ERROR]: Invalid docs.json: ${
963
+ error instanceof Error ? error.message : error
964
+ }`
965
+ );
966
+ }
967
+ })();
968
+
969
+ return configCache;
970
+ }
971
+
972
+ // Validate that only known components are used in MDX content
973
+ function validateComponentUsage(content: string): void {
974
+ // Remove frontmatter before checking
975
+ const contentWithoutFrontmatter = content.replace(/^---[\s\S]*?---\n?/, "");
976
+
977
+ // Extract imported component names from MDX import statements
978
+ // Matches: import ComponentName from "..." or import { ComponentName } from "..."
979
+ const importedComponents: string[] = [];
980
+ const defaultImportRegex = /import\s+([A-Z][a-zA-Z0-9]*)\s+from/g;
981
+ const namedImportRegex = /import\s*\{([^}]+)\}\s*from/g;
982
+
983
+ let importMatch;
984
+ while (
985
+ (importMatch = defaultImportRegex.exec(contentWithoutFrontmatter)) !== null
986
+ ) {
987
+ importedComponents.push(importMatch[1]);
988
+ }
989
+ while (
990
+ (importMatch = namedImportRegex.exec(contentWithoutFrontmatter)) !== null
991
+ ) {
992
+ // Parse named imports like { Foo, Bar as Baz }
993
+ const names = importMatch[1].split(",").map((n) => {
994
+ const parts = n.trim().split(/\s+as\s+/);
995
+ return parts[parts.length - 1].trim(); // Use the alias if present
996
+ });
997
+ importedComponents.push(...names.filter((n) => /^[A-Z]/.test(n)));
998
+ }
999
+
1000
+ // Combine available components with imported ones
1001
+ const allowedComponents = [...AVAILABLE_COMPONENTS, ...importedComponents];
1002
+
1003
+ // Remove code blocks, inline code, and JSX string expressions to avoid false positives
1004
+ const contentWithoutCode = contentWithoutFrontmatter
1005
+ .replace(/````[\s\S]*?````/g, "") // 4-backtick code blocks (check first, they're longer)
1006
+ .replace(/```[\s\S]*?```/g, "") // fenced code blocks
1007
+ .replace(/`[^`]+`/g, "") // inline code
1008
+ .replace(/\{['"][^'"]*['"]\}/g, ""); // JSX string expressions like {'<Component />'}
1009
+
1010
+ // Find all JSX component tags (PascalCase tags)
1011
+ // Matches: <ComponentName, <ComponentName>, <ComponentName />, etc.
1012
+ const componentRegex = /<([A-Z][a-zA-Z0-9]*)/g;
1013
+ let match;
1014
+
1015
+ const unknownComponents: string[] = [];
1016
+
1017
+ while ((match = componentRegex.exec(contentWithoutCode)) !== null) {
1018
+ const componentName = match[1];
1019
+ if (!allowedComponents.includes(componentName)) {
1020
+ // Avoid duplicate entries
1021
+ if (!unknownComponents.includes(componentName)) {
1022
+ unknownComponents.push(componentName);
1023
+ }
1024
+ }
1025
+ }
1026
+
1027
+ if (unknownComponents.length > 0) {
1028
+ const componentList = unknownComponents.map((c) => `<${c}>`).join(", ");
1029
+ throw new Error(
1030
+ `Unknown component(s): ${componentList}. ` +
1031
+ `Available components are: ${AVAILABLE_COMPONENTS.join(", ")}. ` +
1032
+ `If writing ABOUT a component, use backticks: \`<ComponentName>\` or JSX strings: {'<ComponentName />'}`
1033
+ );
1034
+ }
1035
+ }
1036
+
1037
+ // Helper to recursively find MDX files
1038
+ function getMdxFiles(dir: string): string[] {
1039
+ let results: string[] = [];
1040
+ const list = fs.readdirSync(dir);
1041
+ list.forEach((file) => {
1042
+ file = path.resolve(dir, file);
1043
+ const stat = fs.statSync(file);
1044
+ if (stat && stat.isDirectory()) {
1045
+ results = results.concat(getMdxFiles(file));
1046
+ } else if (file.endsWith(".mdx")) {
1047
+ results.push(file);
1048
+ }
1049
+ });
1050
+ return results;
1051
+ }
1052
+
1053
+ // MDX Validation Function
1054
+ export async function validateMdxContent() {
1055
+ const files = getMdxFiles(DOCS_DIR);
1056
+
1057
+ for (const file of files) {
1058
+ try {
1059
+ const content = fs.readFileSync(file, "utf-8");
1060
+
1061
+ // Check for Frontmatter
1062
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
1063
+
1064
+ if (match) {
1065
+ // Parse only if it exists
1066
+ const frontmatter = yaml.parse(match[1]);
1067
+
1068
+ // Validate against shared schema
1069
+ const result = docsSchema.safeParse(frontmatter);
1070
+
1071
+ if (!result.success) {
1072
+ const issue = result.error.issues[0];
1073
+ const pathStr = issue.path.join(".");
1074
+ // Throw clean error
1075
+ throw new Error(
1076
+ `Frontmatter validation failed: ${issue.message} (at: ${pathStr})`
1077
+ );
1078
+ }
1079
+ }
1080
+
1081
+ // Validate component usage BEFORE compiling
1082
+ validateComponentUsage(content);
1083
+
1084
+ // Compile just to check syntax
1085
+ await compile(content, { jsx: true });
1086
+ } catch (e: any) {
1087
+ const relativePath = path.relative(DOCS_DIR, file);
1088
+ const location = e.line ? `:${e.line}:${e.column}` : "";
1089
+ const reason = e.reason || e.message;
1090
+
1091
+ // Throw clean error
1092
+ throw new Error(
1093
+ `[USER_ERROR]: Invalid MDX in ${relativePath}${location} -> ${reason}`
1094
+ );
1095
+ }
1096
+ }
1097
+ }