radiant-docs 0.1.7 → 0.1.8

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 (78) hide show
  1. package/dist/index.js +28 -5
  2. package/package.json +3 -3
  3. package/template/astro.config.mjs +76 -3
  4. package/template/package-lock.json +924 -737
  5. package/template/package.json +7 -5
  6. package/template/scripts/generate-og-images.mjs +335 -0
  7. package/template/scripts/generate-og-metadata.mjs +173 -0
  8. package/template/scripts/rewrite-static-asset-host.mjs +408 -0
  9. package/template/scripts/stamp-image-versions.mjs +277 -0
  10. package/template/scripts/stamp-og-image-versions.mjs +199 -0
  11. package/template/scripts/stamp-pagefind-runtime-version.mjs +140 -0
  12. package/template/src/assets/fonts/geist-mono/cyrillic.woff2 +0 -0
  13. package/template/src/assets/fonts/geist-mono/latin-ext.woff2 +0 -0
  14. package/template/src/assets/fonts/geist-mono/latin.woff2 +0 -0
  15. package/template/src/assets/fonts/google-sans-flex/canadian-aboriginal.woff2 +0 -0
  16. package/template/src/assets/fonts/google-sans-flex/cherokee.woff2 +0 -0
  17. package/template/src/assets/fonts/google-sans-flex/latin-ext.woff2 +0 -0
  18. package/template/src/assets/fonts/google-sans-flex/latin.woff2 +0 -0
  19. package/template/src/assets/fonts/google-sans-flex/math.woff2 +0 -0
  20. package/template/src/assets/fonts/google-sans-flex/nushu.woff2 +0 -0
  21. package/template/src/assets/fonts/google-sans-flex/symbols.woff2 +0 -0
  22. package/template/src/assets/fonts/google-sans-flex/syriac.woff2 +0 -0
  23. package/template/src/assets/fonts/google-sans-flex/tifinagh.woff2 +0 -0
  24. package/template/src/assets/fonts/google-sans-flex/vietnamese.woff2 +0 -0
  25. package/template/src/components/Footer.astro +94 -0
  26. package/template/src/components/Header.astro +11 -66
  27. package/template/src/components/LogoLink.astro +103 -0
  28. package/template/src/components/MdxPage.astro +126 -11
  29. package/template/src/components/OpenApiPage.astro +1036 -69
  30. package/template/src/components/Search.astro +0 -2
  31. package/template/src/components/SidebarDropdown.astro +34 -14
  32. package/template/src/components/SidebarGroup.astro +3 -6
  33. package/template/src/components/SidebarLink.astro +22 -12
  34. package/template/src/components/SidebarMenu.astro +19 -16
  35. package/template/src/components/SidebarSegmented.astro +99 -0
  36. package/template/src/components/SidebarSubgroup.astro +12 -12
  37. package/template/src/components/ThemeSwitcher.astro +30 -7
  38. package/template/src/components/endpoint/PlaygroundBar.astro +32 -36
  39. package/template/src/components/endpoint/PlaygroundButton.astro +40 -4
  40. package/template/src/components/endpoint/PlaygroundField.astro +1068 -22
  41. package/template/src/components/endpoint/PlaygroundForm.astro +559 -61
  42. package/template/src/components/endpoint/RequestSnippets.astro +342 -193
  43. package/template/src/components/endpoint/ResponseDisplay.astro +161 -147
  44. package/template/src/components/endpoint/ResponseFieldTree.astro +134 -0
  45. package/template/src/components/endpoint/ResponseFields.astro +711 -68
  46. package/template/src/components/endpoint/ResponseSnippets.astro +299 -173
  47. package/template/src/components/sidebar/SidebarEndpointLink.astro +1 -1
  48. package/template/src/components/ui/CodeLanguageIcon.astro +19 -0
  49. package/template/src/components/ui/CodeTabEdge.astro +79 -0
  50. package/template/src/components/ui/Field.astro +103 -20
  51. package/template/src/components/ui/Icon.astro +32 -0
  52. package/template/src/components/ui/ListChevronsToggle.astro +31 -0
  53. package/template/src/components/ui/Tag.astro +1 -1
  54. package/template/src/components/user/{Accordian.astro → Accordion.astro} +6 -6
  55. package/template/src/components/user/Callout.astro +5 -9
  56. package/template/src/components/user/CodeBlock.astro +400 -0
  57. package/template/src/components/user/CodeGroup.astro +225 -0
  58. package/template/src/components/user/ComponentPreview.astro +1 -0
  59. package/template/src/components/user/ComponentPreviewBlock.astro +181 -0
  60. package/template/src/components/user/Image.astro +132 -0
  61. package/template/src/components/user/Steps.astro +1 -3
  62. package/template/src/components/user/Tabs.astro +2 -2
  63. package/template/src/content.config.ts +1 -0
  64. package/template/src/layouts/Layout.astro +109 -8
  65. package/template/src/lib/code/code-block.ts +546 -0
  66. package/template/src/lib/frontmatter-schema.ts +8 -7
  67. package/template/src/lib/mdx/remark-code-block-component.ts +342 -0
  68. package/template/src/lib/mdx/remark-demote-h1.ts +16 -0
  69. package/template/src/lib/pagefind.ts +19 -5
  70. package/template/src/lib/routes.ts +49 -31
  71. package/template/src/lib/utils.ts +20 -0
  72. package/template/src/lib/validation.ts +638 -200
  73. package/template/src/pages/[...slug].astro +18 -5
  74. package/template/src/styles/geist-mono.css +33 -0
  75. package/template/src/styles/global.css +89 -84
  76. package/template/src/styles/google-sans-flex.css +143 -0
  77. package/template/ec.config.mjs +0 -51
  78. /package/template/src/components/user/{AccordianGroup.astro → AccordionGroup.astro} +0 -0
@@ -12,6 +12,95 @@ const CWD = process.cwd();
12
12
  const DOCS_DIR = path.join(CWD, "src/content/docs");
13
13
  const CONFIG_PATH = path.join(DOCS_DIR, "docs.json");
14
14
 
15
+ // Cache for icon sets (key: prefix, value: Set of icon names)
16
+ const iconSets = new Map<string, Set<string>>();
17
+
18
+ // Helper function to check if a string is a URL
19
+ function isUrl(str: string): boolean {
20
+ try {
21
+ const url = new URL(str);
22
+ return url.protocol === "http:" || url.protocol === "https:";
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ function getIconSet(prefix: string): Set<string> | null {
29
+ if (iconSets.has(prefix)) return iconSets.get(prefix)!;
30
+
31
+ try {
32
+ const iconsPath = path.join(
33
+ CWD,
34
+ `node_modules/@iconify-json/${prefix}/icons.json`,
35
+ );
36
+ if (!fs.existsSync(iconsPath)) {
37
+ return null;
38
+ }
39
+ const iconsData = JSON.parse(fs.readFileSync(iconsPath, "utf-8"));
40
+ const set = new Set(Object.keys(iconsData.icons));
41
+ iconSets.set(prefix, set);
42
+ return set;
43
+ } catch (error) {
44
+ console.error(`Failed to load icon set for prefix "${prefix}":`, error);
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function validateIcon(icon: any, currentPath: Path): void {
50
+ if (icon === undefined || icon === null) return;
51
+
52
+ if (typeof icon !== "string") {
53
+ throwConfigError("Icon must be a string.", currentPath);
54
+ }
55
+
56
+ // 1. Handle remote URLs
57
+ if (isUrl(icon)) {
58
+ return;
59
+ }
60
+
61
+ // 2. Handle Iconify icons (prefix:name)
62
+ if (icon.includes(":")) {
63
+ const parts = icon.split(":");
64
+
65
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
66
+ throwConfigError(
67
+ `Invalid library icon format: "${icon}". Icons must follow the "library-prefix:name" format (e.g., "lucide:home") or be a local path.`,
68
+ currentPath,
69
+ );
70
+ }
71
+
72
+ const [prefix, name] = parts;
73
+ const icons = getIconSet(prefix);
74
+
75
+ if (icons) {
76
+ if (!icons.has(name)) {
77
+ throwConfigError(
78
+ `Invalid icon name: "${name}" for library "${prefix}". Is this a typo?`,
79
+ currentPath,
80
+ );
81
+ }
82
+ } else {
83
+ throwConfigError(
84
+ `Invalid icon library: "${prefix}". Is this package installed in @iconify-json?`,
85
+ currentPath,
86
+ );
87
+ }
88
+ return;
89
+ }
90
+
91
+ // 3. Handle local icons
92
+ // Check if it's a file path (must exist in DOCS_DIR)
93
+ const localRelativePath = icon.startsWith("/") ? icon.slice(1) : icon;
94
+ const localPath = path.join(DOCS_DIR, localRelativePath);
95
+
96
+ if (!fs.existsSync(localPath)) {
97
+ throwConfigError(
98
+ `Icon not found: "${icon}". Local icons must exist in your repository. Did you mean to use an library icon like "lucide:home"?`,
99
+ currentPath,
100
+ );
101
+ }
102
+ }
103
+
15
104
  // Define the list of available user components for MDX
16
105
  const AVAILABLE_COMPONENTS = [
17
106
  "Callout",
@@ -19,15 +108,27 @@ const AVAILABLE_COMPONENTS = [
19
108
  "Tab",
20
109
  "Steps",
21
110
  "Step",
22
- "Accordian",
23
- "AccordianGroup",
111
+ "Accordion",
112
+ "AccordionGroup",
113
+ "Image",
114
+ "CodeGroup",
115
+ "ComponentPreview",
24
116
  ];
25
117
 
26
- export type NavPage = { page: string; icon?: string; tag?: string };
118
+ // Internal components can be valid in MDX while remaining hidden from
119
+ // user-facing error guidance.
120
+ const INTERNAL_ONLY_COMPONENTS = new Set(["ComponentPreview"]);
121
+
122
+ export type NavPage = {
123
+ page: string;
124
+ icon?: string | null;
125
+ tag?: string;
126
+ title?: string;
127
+ };
27
128
  export type NavGroup = {
28
129
  group: string;
29
130
  pages: (string | NavPage | NavGroup)[];
30
- icon?: string;
131
+ icon?: string | null;
31
132
  expanded?: boolean; // need to add this logic
32
133
  tag?: string;
33
134
  };
@@ -37,8 +138,7 @@ export type NavOpenApi = {
37
138
  exclude?: string[];
38
139
  };
39
140
  export type NavigationItem = {
40
- pages?: (string | NavPage)[];
41
- groups?: NavGroup[];
141
+ pages?: (string | NavPage | NavGroup)[];
42
142
  menu?: NavMenu;
43
143
  openapi?: string | NavOpenApi;
44
144
  };
@@ -46,22 +146,30 @@ export type NavigationItem = {
46
146
  export type NavMenuItem = {
47
147
  label: string;
48
148
  submenu: Omit<NavigationItem, "menu">;
49
- icon?: string;
149
+ icon?: string | null;
50
150
  };
51
151
  export type NavMenu = {
52
- type?: "dropdown" | "collapsible";
152
+ type?: "dropdown" | "segmented";
53
153
  label?: string;
54
154
  items: NavMenuItem[];
55
155
  };
56
156
  export type NavbarItem = {
57
157
  text: string;
58
158
  href: string;
59
- icon?: string;
159
+ icon?: string | null;
160
+ };
161
+ export type LogoVariant = string | {
162
+ image: string;
163
+ padding?: {
164
+ top?: number;
165
+ bottom?: number;
166
+ };
60
167
  };
61
168
  export type Logo = {
62
- light?: string;
63
- dark?: string;
169
+ light?: LogoVariant;
170
+ dark?: LogoVariant;
64
171
  href?: string;
172
+ pill?: string | false;
65
173
  };
66
174
  export type DocsConfig = {
67
175
  title: string;
@@ -74,6 +182,36 @@ export type DocsConfig = {
74
182
  secondary?: NavbarItem;
75
183
  links?: NavbarItem[];
76
184
  };
185
+ playground?: {
186
+ proxy?: boolean;
187
+ };
188
+ footer?: Footer;
189
+ };
190
+
191
+ export type SocialPlatform =
192
+ | "x"
193
+ | "website"
194
+ | "facebook"
195
+ | "youtube"
196
+ | "discord"
197
+ | "slack"
198
+ | "github"
199
+ | "linkedin"
200
+ | "instagram"
201
+ | "hacker-news"
202
+ | "medium"
203
+ | "telegram"
204
+ | "bluesky"
205
+ | "threads"
206
+ | "reddit"
207
+ | "podcast";
208
+ export type FooterLink = {
209
+ text: string;
210
+ href: string;
211
+ };
212
+ export type Footer = {
213
+ socials?: Partial<Record<SocialPlatform, string>>;
214
+ links?: FooterLink[];
77
215
  };
78
216
  type Path = (string | number)[];
79
217
 
@@ -92,9 +230,9 @@ function checkType(
92
230
  value: any,
93
231
  type: "string" | "boolean" | "array" | "object",
94
232
  currentPath: Path,
95
- label: string
233
+ label: string,
96
234
  ): void {
97
- if (value === undefined) return;
235
+ if (value === undefined || value === null) return;
98
236
 
99
237
  if (type === "array") {
100
238
  if (!Array.isArray(value))
@@ -115,19 +253,37 @@ function validateFileExistence(filePath: string, currentPath: Path): void {
115
253
  if (!fs.existsSync(fullPath)) {
116
254
  throwConfigError(
117
255
  `Referenced file not found. Expected: ${filePath}`,
118
- currentPath
256
+ currentPath,
119
257
  );
120
258
  }
121
259
  }
122
260
 
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;
261
+ function normalizeDocsPagePath(
262
+ value: string,
263
+ currentPath: Path,
264
+ label: string = "Page path",
265
+ ): string {
266
+ checkType(value, "string", currentPath, label);
267
+
268
+ const trimmedPath = value.trim();
269
+ if (trimmedPath === "") {
270
+ throwConfigError(`${label} cannot be an empty string`, currentPath);
271
+ }
272
+
273
+ if (isUrl(trimmedPath)) {
274
+ throwConfigError(
275
+ `${label} must reference a documentation page path, not a URL`,
276
+ currentPath,
277
+ );
278
+ }
279
+
280
+ const normalizedPath = trimmedPath.replace(/^\/+/, "").replace(/\/+$/, "");
281
+
282
+ if (normalizedPath === "") {
283
+ throwConfigError(`${label} cannot be '/'`, currentPath);
130
284
  }
285
+
286
+ return normalizedPath;
131
287
  }
132
288
 
133
289
  // Cache for OpenAPI specs (key: filePathOrUrl, value: parsed spec)
@@ -150,7 +306,7 @@ export async function loadOpenApiSpec(filePathOrUrl: string): Promise<any> {
150
306
  const response = await fetch(filePathOrUrl);
151
307
  if (!response.ok) {
152
308
  throw new Error(
153
- `Failed to fetch OpenAPI spec: ${response.status} ${response.statusText}`
309
+ `Failed to fetch OpenAPI spec: ${response.status} ${response.statusText}`,
154
310
  );
155
311
  }
156
312
  fileContent = await response.text();
@@ -158,7 +314,7 @@ export async function loadOpenApiSpec(filePathOrUrl: string): Promise<any> {
158
314
  throw new Error(
159
315
  `Failed to fetch OpenAPI spec from URL: ${
160
316
  error instanceof Error ? error.message : String(error)
161
- }`
317
+ }`,
162
318
  );
163
319
  }
164
320
  } else {
@@ -174,7 +330,7 @@ export async function loadOpenApiSpec(filePathOrUrl: string): Promise<any> {
174
330
  trimmedContent.startsWith("<html")
175
331
  ) {
176
332
  throw new Error(
177
- "The URL does not return a valid OpenAPI specification. The URL appears to return HTML instead of JSON or YAML."
333
+ "The URL does not return a valid OpenAPI specification. The URL appears to return HTML instead of JSON or YAML.",
178
334
  );
179
335
  }
180
336
 
@@ -193,7 +349,7 @@ export async function loadOpenApiSpec(filePathOrUrl: string): Promise<any> {
193
349
  } catch (parseError) {
194
350
  if (parseError instanceof SyntaxError) {
195
351
  throw new Error(
196
- `The URL does not return a valid OpenAPI specification. Failed to parse as JSON or YAML: ${parseError.message}`
352
+ `The URL does not return a valid OpenAPI specification. Failed to parse as JSON or YAML: ${parseError.message}`,
197
353
  );
198
354
  }
199
355
  throw parseError;
@@ -212,13 +368,13 @@ async function validateOpenApiFile(filePathOrUrl: string, currentPath: Path) {
212
368
  // For local files, validate extension and existence
213
369
  const validExtensions = [".json", ".yaml", ".yml"];
214
370
  const hasValidExtension = validExtensions.some((ext) =>
215
- filePathOrUrl.toLowerCase().endsWith(ext)
371
+ filePathOrUrl.toLowerCase().endsWith(ext),
216
372
  );
217
373
 
218
374
  if (!hasValidExtension) {
219
375
  throwConfigError(
220
376
  `OpenAPI file must have a valid extension (.json, .yaml, or .yml). Found: ${filePathOrUrl}`,
221
- currentPath
377
+ currentPath,
222
378
  );
223
379
  }
224
380
 
@@ -227,7 +383,7 @@ async function validateOpenApiFile(filePathOrUrl: string, currentPath: Path) {
227
383
  if (!fs.existsSync(fullPath)) {
228
384
  throwConfigError(
229
385
  `Referenced OpenAPI file not found. Expected: ${filePathOrUrl}`,
230
- currentPath
386
+ currentPath,
231
387
  );
232
388
  }
233
389
  } else {
@@ -237,13 +393,13 @@ async function validateOpenApiFile(filePathOrUrl: string, currentPath: Path) {
237
393
  if (url.protocol !== "http:" && url.protocol !== "https:") {
238
394
  throwConfigError(
239
395
  `OpenAPI URL must use http:// or https:// protocol. Found: ${filePathOrUrl}`,
240
- currentPath
396
+ currentPath,
241
397
  );
242
398
  }
243
399
  } catch (error) {
244
400
  throwConfigError(
245
401
  `Invalid OpenAPI URL format: ${filePathOrUrl}`,
246
- currentPath
402
+ currentPath,
247
403
  );
248
404
  }
249
405
  }
@@ -251,37 +407,70 @@ async function validateOpenApiFile(filePathOrUrl: string, currentPath: Path) {
251
407
  // Validate the OpenAPI spec using Spectral (works for both files and URLs)
252
408
  try {
253
409
  const document = await loadOpenApiSpec(filePathOrUrl);
254
-
255
- const basicRuleset = {
256
- formats: oas.formats,
410
+ const tolerantRuleset = {
411
+ ...oas,
257
412
  rules: {
258
- "oas3-schema": oas.rules["oas3-schema"],
413
+ ...oas.rules,
414
+ "oas3-schema": {
415
+ ...oas.rules["oas3-schema"],
416
+ severity: 1,
417
+ },
418
+ "oas2-schema": {
419
+ ...oas.rules["oas2-schema"],
420
+ severity: 1,
421
+ },
259
422
  },
260
423
  };
261
424
 
262
425
  const spectral = new Spectral();
263
426
 
264
- spectral.setRuleset(basicRuleset);
427
+ spectral.setRuleset(tolerantRuleset);
265
428
 
266
429
  let results = await spectral.run(document);
430
+ const blockingResults = results.filter(
431
+ (result) => result.code === "unrecognized-format",
432
+ );
267
433
 
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
- });
434
+ if (blockingResults.length > 0) {
435
+ const errorMessages = blockingResults.slice(0, 5).map((result) => {
436
+ const pathStr = result.path.length > 0 ? result.path.join(".") : "root";
437
+ return `${result.message} (at ${pathStr})`;
438
+ });
277
439
 
278
440
  const errorText = errorMessages.join("; ");
279
441
  const moreErrors =
280
- results.length > 5 ? ` (and ${results.length - 5} more errors)` : "";
442
+ blockingResults.length > 5
443
+ ? ` (and ${blockingResults.length - 5} more errors)`
444
+ : "";
281
445
 
282
446
  throwConfigError(
283
447
  `Invalid OpenAPI specification: ${errorText}${moreErrors}`,
284
- currentPath
448
+ currentPath,
449
+ );
450
+ }
451
+
452
+ const nonBlockingResults = results.filter(
453
+ (result) => result.severity !== 0,
454
+ );
455
+
456
+ if (nonBlockingResults.length > 0) {
457
+ const warningMessages = nonBlockingResults.slice(0, 5).map((result) => {
458
+ const pathStr = result.path.length > 0 ? result.path.join(".") : "root";
459
+ return `${result.message} (at ${pathStr})`;
460
+ });
461
+
462
+ const warningText = warningMessages.join("; ");
463
+ const moreWarnings =
464
+ nonBlockingResults.length > warningMessages.length
465
+ ? ` (and ${
466
+ nonBlockingResults.length - warningMessages.length
467
+ } more warnings)`
468
+ : "";
469
+ const sourcePath =
470
+ currentPath.length > 0 ? ` (at: ${currentPath.join(".")})` : "";
471
+
472
+ console.warn(
473
+ `[OPENAPI_VALIDATION_WARNING] ${warningText}${moreWarnings}${sourcePath}`,
285
474
  );
286
475
  }
287
476
  } catch (error) {
@@ -289,17 +478,20 @@ async function validateOpenApiFile(filePathOrUrl: string, currentPath: Path) {
289
478
  if (error instanceof SyntaxError) {
290
479
  throwConfigError(
291
480
  `Failed to parse OpenAPI file: ${error.message}`,
292
- currentPath
481
+ currentPath,
293
482
  );
294
483
  } else if (error instanceof Error) {
295
- throwConfigError(
296
- `Invalid OpenAPI specification: ${error.message}`,
297
- currentPath
298
- );
484
+ const baseMessage = error.message || String(error);
485
+ const prefixedMessage = baseMessage.startsWith(
486
+ "Invalid OpenAPI specification:",
487
+ )
488
+ ? baseMessage
489
+ : `Invalid OpenAPI specification: ${baseMessage}`;
490
+ throwConfigError(prefixedMessage, currentPath);
299
491
  } else {
300
492
  throwConfigError(
301
493
  `Invalid OpenAPI specification: ${String(error)}`,
302
- currentPath
494
+ currentPath,
303
495
  );
304
496
  }
305
497
  }
@@ -338,7 +530,7 @@ function extractAvailableEndpoints(openApiDoc: any): Set<string> {
338
530
 
339
531
  // Helper function to parse endpoint string (e.g., "get /burgers" or "POST /burgers")
340
532
  function parseEndpointString(
341
- endpointStr: string
533
+ endpointStr: string,
342
534
  ): { method: string; path: string } | null {
343
535
  const trimmed = endpointStr.trim();
344
536
  const parts = trimmed.split(/\s+/);
@@ -361,10 +553,15 @@ function parseEndpointString(
361
553
  return { method, path: normalizedPath };
362
554
  }
363
555
 
364
- function validateNavigationNode(item: any, currentPath: Path): void {
556
+ function validateNavigationNode(
557
+ item: any,
558
+ currentPath: Path,
559
+ groupDepth: number = 0,
560
+ ): void {
365
561
  // A) Base Case: Simple string path
366
562
  if (typeof item === "string") {
367
- validateFileExistence(item, currentPath);
563
+ const normalizedPath = normalizeDocsPagePath(item, currentPath);
564
+ validateFileExistence(normalizedPath, currentPath);
368
565
  return;
369
566
  }
370
567
 
@@ -379,25 +576,41 @@ function validateNavigationNode(item: any, currentPath: Path): void {
379
576
  if (typeCount !== 1) {
380
577
  throwConfigError(
381
578
  "Object must contain exactly one key: 'page' or 'group'.",
382
- currentPath
579
+ currentPath,
383
580
  );
384
581
  }
385
582
 
386
583
  // --- Validate Group (Recursive) ---
387
584
  if (isGroup) {
388
585
  const path = [...currentPath];
586
+
587
+ // Enforce max group nesting depth of 2
588
+ if (groupDepth >= 2) {
589
+ throwConfigError("Groups can only be nested up to 2 levels deep.", path);
590
+ }
591
+
389
592
  checkType(item.group, "string", [...path, "group"], "Group name");
390
593
 
391
594
  // C.2: THE EXPANDED CHECK (Kept clean)
392
595
  checkType(item.expanded, "boolean", [...path, "expanded"], "Expanded");
393
596
 
597
+ validateIcon(item.icon, [...path, "icon"]);
598
+
394
599
  // Check if pages array exists and validate children
395
600
  if (!item.pages)
396
601
  throwConfigError("Group must have a 'pages' array.", [...path, "pages"]);
397
602
  checkType(item.pages, "array", [...path, "pages"], "Group pages");
398
603
 
399
604
  item.pages.forEach((child: any, i: number) => {
400
- validateNavigationNode(child, [...path, "pages", i]); // Recursive call
605
+ if (typeof child === "string") {
606
+ const childPath = [...path, "pages", i];
607
+ const normalizedPagePath = normalizeDocsPagePath(child, childPath);
608
+ item.pages[i] = normalizedPagePath;
609
+ validateFileExistence(normalizedPagePath, childPath);
610
+ return;
611
+ }
612
+
613
+ validateNavigationNode(child, [...path, "pages", i], groupDepth + 1);
401
614
  });
402
615
  return;
403
616
  }
@@ -405,9 +618,17 @@ function validateNavigationNode(item: any, currentPath: Path): void {
405
618
  // --- Validate Page ---
406
619
  if (isPage) {
407
620
  const path = [...currentPath];
408
- checkType(item.page, "string", [...path, "page"], "Page path");
621
+ const normalizedPagePath = normalizeDocsPagePath(item.page, [
622
+ ...path,
623
+ "page",
624
+ ]);
625
+ item.page = normalizedPagePath;
626
+ validateFileExistence(normalizedPagePath, [...path, "page"]);
627
+
628
+ validateIcon(item.icon, [...path, "icon"]);
409
629
 
410
- validateFileExistence(item.page, [...path, "page"]);
630
+ // Validate optional title
631
+ checkType(item.title, "string", [...path, "title"], "Page title");
411
632
 
412
633
  // Check D.2/D.3: Page cannot have group properties
413
634
  if ("expanded" in item)
@@ -421,9 +642,56 @@ function validateNavigationNode(item: any, currentPath: Path): void {
421
642
  }
422
643
  }
423
644
 
645
+ function getFirstPagePathFromPageItems(
646
+ items: (string | NavPage | NavGroup)[],
647
+ ): string | undefined {
648
+ for (const item of items) {
649
+ if (typeof item === "string") {
650
+ return item;
651
+ }
652
+
653
+ if ("page" in item) {
654
+ return item.page;
655
+ }
656
+
657
+ if ("group" in item) {
658
+ const nestedPath = getFirstPagePathFromPageItems(item.pages);
659
+ if (nestedPath) {
660
+ return nestedPath;
661
+ }
662
+ }
663
+ }
664
+
665
+ return undefined;
666
+ }
667
+
668
+ function getFirstPagePathFromNavigation(
669
+ navigation: DocsConfig["navigation"],
670
+ ): string | undefined {
671
+ if (navigation.pages) {
672
+ return getFirstPagePathFromPageItems(navigation.pages);
673
+ }
674
+
675
+ if (navigation.menu) {
676
+ for (const menuItem of navigation.menu.items) {
677
+ const submenuPages = menuItem.submenu.pages;
678
+ if (!submenuPages) {
679
+ continue;
680
+ }
681
+
682
+ const firstPath = getFirstPagePathFromPageItems(submenuPages);
683
+ if (firstPath) {
684
+ return firstPath;
685
+ }
686
+ }
687
+ }
688
+
689
+ return undefined;
690
+ }
691
+
424
692
  async function validateNavOpenApi(
425
693
  navOpenApi: any,
426
- currentPath: Path
694
+ currentPath: Path,
427
695
  ): Promise<void> {
428
696
  checkType(navOpenApi, "object", currentPath, "Open API object");
429
697
 
@@ -431,7 +699,7 @@ async function validateNavOpenApi(
431
699
  if (typeof navOpenApi.source !== "string") {
432
700
  throwConfigError(
433
701
  "Open API object must have an 'source' property that is a string.",
434
- [...currentPath, "source"]
702
+ [...currentPath, "source"],
435
703
  );
436
704
  }
437
705
 
@@ -445,7 +713,7 @@ async function validateNavOpenApi(
445
713
  if (hasInclude && hasExclude) {
446
714
  throwConfigError(
447
715
  "Open API object cannot have both 'include' and 'exclude' properties. They are mutually exclusive.",
448
- currentPath
716
+ currentPath,
449
717
  );
450
718
  }
451
719
 
@@ -464,7 +732,7 @@ async function validateNavOpenApi(
464
732
  navOpenApi.include,
465
733
  "array",
466
734
  [...currentPath, "include"],
467
- "Include array"
735
+ "Include array",
468
736
  );
469
737
 
470
738
  if (navOpenApi.include.length === 0) {
@@ -479,7 +747,7 @@ async function validateNavOpenApi(
479
747
  if (typeof entry !== "string") {
480
748
  throwConfigError(
481
749
  `Include entry at index ${i} must be a string in the format "METHOD /path".`,
482
- [...currentPath, "include", i]
750
+ [...currentPath, "include", i],
483
751
  );
484
752
  }
485
753
 
@@ -487,7 +755,7 @@ async function validateNavOpenApi(
487
755
  if (!parsed) {
488
756
  throwConfigError(
489
757
  `Include entry at index ${i} must be in the format "METHOD /path". Found: ${entry}`,
490
- [...currentPath, "include", i]
758
+ [...currentPath, "include", i],
491
759
  );
492
760
  }
493
761
 
@@ -496,7 +764,7 @@ async function validateNavOpenApi(
496
764
  if (!availableEndpoints.has(endpointKey)) {
497
765
  throwConfigError(
498
766
  `Include entry at index ${i} does not match any endpoint in the OpenAPI spec. Found: ${entry}. Expected format: "METHOD /path".`,
499
- [...currentPath, "include", i]
767
+ [...currentPath, "include", i],
500
768
  );
501
769
  }
502
770
  }
@@ -508,7 +776,7 @@ async function validateNavOpenApi(
508
776
  navOpenApi.exclude,
509
777
  "array",
510
778
  [...currentPath, "exclude"],
511
- "Exclude array"
779
+ "Exclude array",
512
780
  );
513
781
 
514
782
  if (navOpenApi.exclude.length === 0) {
@@ -523,7 +791,7 @@ async function validateNavOpenApi(
523
791
  if (typeof entry !== "string") {
524
792
  throwConfigError(
525
793
  `Exclude entry at index ${i} must be a string in the format "METHOD /path".`,
526
- [...currentPath, "exclude", i]
794
+ [...currentPath, "exclude", i],
527
795
  );
528
796
  }
529
797
 
@@ -531,7 +799,7 @@ async function validateNavOpenApi(
531
799
  if (!parsed) {
532
800
  throwConfigError(
533
801
  `Exclude entry at index ${i} must be in the format "METHOD /path" (e.g., "get /burgers"). Found: ${entry}`,
534
- [...currentPath, "exclude", i]
802
+ [...currentPath, "exclude", i],
535
803
  );
536
804
  }
537
805
 
@@ -540,7 +808,7 @@ async function validateNavOpenApi(
540
808
  if (!availableEndpoints.has(endpointKey)) {
541
809
  throwConfigError(
542
810
  `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]
811
+ [...currentPath, "exclude", i],
544
812
  );
545
813
  }
546
814
  }
@@ -550,7 +818,7 @@ async function validateNavOpenApi(
550
818
  async function validateNavMenuItem(item: NavMenuItem, currentPath: Path) {
551
819
  checkType(item, "object", currentPath, "Menu item");
552
820
 
553
- checkType(item.icon, "string", [...currentPath, "icon"], "Menu item icon");
821
+ validateIcon(item.icon, [...currentPath, "icon"]);
554
822
 
555
823
  // Required: label
556
824
  if (!item.label) {
@@ -572,12 +840,12 @@ async function validateNavMenuItem(item: NavMenuItem, currentPath: Path) {
572
840
 
573
841
  const submenu = item.submenu;
574
842
  const submenuKeys = Object.keys(submenu);
575
- const validSubmenuKeys = ["pages", "groups", "openapi"];
843
+ const validSubmenuKeys = ["pages", "openapi"];
576
844
  const presentSubmenuKeys = submenuKeys.filter((key) =>
577
- validSubmenuKeys.includes(key)
845
+ validSubmenuKeys.includes(key),
578
846
  );
579
847
  const invalidSubmenuKeys = submenuKeys.filter(
580
- (key) => !validSubmenuKeys.includes(key)
848
+ (key) => !validSubmenuKeys.includes(key),
581
849
  );
582
850
 
583
851
  // Submenu must have exactly one key total
@@ -585,16 +853,16 @@ async function validateNavMenuItem(item: NavMenuItem, currentPath: Path) {
585
853
  if (submenuKeys.length === 0) {
586
854
  throwConfigError(
587
855
  `Submenu must contain exactly one key (${validSubmenuKeys.join(
588
- ", "
856
+ ", ",
589
857
  )}). Found no keys.`,
590
- [...currentPath, "submenu"]
858
+ [...currentPath, "submenu"],
591
859
  );
592
860
  } else {
593
861
  throwConfigError(
594
862
  `Submenu must contain exactly one key (${validSubmenuKeys.join(
595
- ", "
863
+ ", ",
596
864
  )}). Found ${submenuKeys.length} key(s): ${submenuKeys.join(", ")}.`,
597
- [...currentPath, "submenu"]
865
+ [...currentPath, "submenu"],
598
866
  );
599
867
  }
600
868
  }
@@ -604,9 +872,9 @@ async function validateNavMenuItem(item: NavMenuItem, currentPath: Path) {
604
872
  const invalidKey = invalidSubmenuKeys[0];
605
873
  throwConfigError(
606
874
  `Submenu must contain exactly one key (${validSubmenuKeys.join(
607
- ", "
875
+ ", ",
608
876
  )}). Found invalid key: ${invalidKey}.`,
609
- [...currentPath, "submenu"]
877
+ [...currentPath, "submenu"],
610
878
  );
611
879
  }
612
880
 
@@ -620,31 +888,20 @@ async function validateNavMenuItem(item: NavMenuItem, currentPath: Path) {
620
888
  submenuValue,
621
889
  "array",
622
890
  [...currentPath, "submenu", "pages"],
623
- "Submenu pages"
891
+ "Submenu pages",
624
892
  );
625
893
  (submenuValue as NavigationItem["pages"])?.forEach(
626
- (page: string | NavPage, i: number) => {
627
- if (typeof page === "string") {
628
- validateFileExistence(page, [...currentPath, "submenu", "pages", i]);
894
+ (item: string | NavPage | NavGroup, i: number) => {
895
+ const itemPath = [...currentPath, "submenu", "pages", i];
896
+ if (typeof item === "string") {
897
+ const normalizedPagePath = normalizeDocsPagePath(item, itemPath);
898
+ (submenuValue as (string | NavPage | NavGroup)[])[i] =
899
+ normalizedPagePath;
900
+ validateFileExistence(normalizedPagePath, itemPath);
629
901
  } else {
630
- validateNavigationNode(page, [...currentPath, "submenu", "pages", i]);
902
+ validateNavigationNode(item, itemPath);
631
903
  }
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
- }
904
+ },
648
905
  );
649
906
  }
650
907
 
@@ -667,7 +924,7 @@ async function validateNavMenuItem(item: NavMenuItem, currentPath: Path) {
667
924
  } else {
668
925
  throwConfigError(
669
926
  "OpenAPI must be either a string (file path or hosted file) or an object.",
670
- [...currentPath, "submenu", "openapi"]
927
+ [...currentPath, "submenu", "openapi"],
671
928
  );
672
929
  }
673
930
  }
@@ -678,10 +935,10 @@ async function validateNavMenu(menu: any, currentPath: Path) {
678
935
 
679
936
  // Optional: type
680
937
  if (menu.type !== undefined) {
681
- if (menu.type !== "dropdown" && menu.type !== "collapsible") {
938
+ if (menu.type !== "dropdown" && menu.type !== "segmented") {
682
939
  throwConfigError(
683
- "Menu type must be 'dropdown' or 'collapsible' if provided. Defaults to 'dropdown'",
684
- [...currentPath, "type"]
940
+ "Menu type must be 'dropdown' or 'segmented' if provided. Defaults to 'dropdown'",
941
+ [...currentPath, "type"],
685
942
  );
686
943
  }
687
944
  }
@@ -725,7 +982,7 @@ function validateNavbarItem(item: any, currentPath: Path): void {
725
982
  }
726
983
 
727
984
  // Optional property
728
- checkType(item.icon, "string", [...currentPath, "icon"], "Navbar icon");
985
+ validateIcon(item.icon, [...currentPath, "icon"]);
729
986
  }
730
987
 
731
988
  // --- Top-Level Validation Functions (Your Clean API) ---
@@ -735,75 +992,131 @@ function validateTitle(title: DocsConfig["title"]) {
735
992
  if (!title) throwConfigError("Title is missing.", ["title"]);
736
993
  }
737
994
 
738
- function validateLogo(logo: DocsConfig["logo"]) {
739
- // Logo is optional, so if it's undefined, we're done
740
- if (logo === undefined) return;
995
+ function validateLogoPaddingValue(value: unknown, currentPath: Path, label: string): void {
996
+ if (value === undefined) return;
741
997
 
742
- // If logo is provided, it must be an object
743
- checkType(logo, "object", ["logo"], "Logo configuration");
998
+ if (typeof value !== "number" || !Number.isFinite(value)) {
999
+ throwConfigError(`${label} must be a finite number.`, currentPath);
1000
+ }
744
1001
 
745
- // Validate 'light' logo if provided
746
- if (logo.light !== undefined) {
747
- checkType(logo.light, "string", ["logo", "light"], "Logo light path");
1002
+ const numericValue = value as number;
1003
+ if (numericValue < 0) {
1004
+ throwConfigError(`${label} cannot be negative.`, currentPath);
1005
+ }
1006
+ }
748
1007
 
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)
1008
+ function validateLogoImagePath(
1009
+ imagePath: string,
1010
+ currentPath: Path,
1011
+ label: string,
1012
+ ): void {
1013
+ const validExtensions = [".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif"];
1014
+ const hasValidExtension = validExtensions.some((ext) =>
1015
+ imagePath.toLowerCase().endsWith(ext),
1016
+ );
1017
+ if (!hasValidExtension) {
1018
+ throwConfigError(
1019
+ `${label} must be a valid image file (.svg, .png, .jpg, .jpeg, .webp, .gif)`,
1020
+ currentPath,
753
1021
  );
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
- }
1022
+ }
760
1023
 
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);
1024
+ const normalizedPath = imagePath.startsWith("/")
1025
+ ? imagePath.slice(1)
1026
+ : imagePath;
1027
+ const fullPath = path.join(DOCS_DIR, normalizedPath);
767
1028
 
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
- }
1029
+ if (!fs.existsSync(fullPath)) {
1030
+ throwConfigError(
1031
+ `${label} file not found. Expected: ${normalizedPath} (relative to content/docs folder)`,
1032
+ currentPath,
1033
+ );
774
1034
  }
1035
+ }
775
1036
 
776
- // Validate 'dark' logo if provided
777
- if (logo.dark !== undefined) {
778
- checkType(logo.dark, "string", ["logo", "dark"], "Logo dark path");
1037
+ function validateLogoVariant(
1038
+ variant: LogoVariant | undefined,
1039
+ currentPath: Path,
1040
+ mode: "light" | "dark",
1041
+ ): void {
1042
+ if (variant === undefined) return;
779
1043
 
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)
1044
+ if (typeof variant === "string") {
1045
+ validateLogoImagePath(variant, currentPath, `Logo ${mode}`);
1046
+ return;
1047
+ }
1048
+
1049
+ if (typeof variant !== "object" || variant === null || Array.isArray(variant)) {
1050
+ throwConfigError(
1051
+ `Logo ${mode} must be a string path or an object with 'image' and optional 'padding'.`,
1052
+ currentPath,
784
1053
  );
785
- if (!hasValidExtension) {
1054
+ }
1055
+
1056
+ const supportedKeys = new Set(["image", "padding"]);
1057
+ for (const key of Object.keys(variant)) {
1058
+ if (!supportedKeys.has(key)) {
786
1059
  throwConfigError(
787
- "Logo dark must be a valid image file (.svg, .png, .jpg, .jpeg, .webp, .gif)",
788
- ["logo", "dark"]
1060
+ `Logo ${mode} object only supports 'image' and 'padding'.`,
1061
+ [...currentPath, key],
789
1062
  );
790
1063
  }
1064
+ }
791
1065
 
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);
1066
+ if (typeof variant.image !== "string") {
1067
+ throwConfigError(
1068
+ `Logo ${mode} object must include an 'image' string.`,
1069
+ [...currentPath, "image"],
1070
+ );
1071
+ }
798
1072
 
799
- if (!fs.existsSync(fullPath)) {
1073
+ validateLogoImagePath(variant.image, [...currentPath, "image"], `Logo ${mode} image`);
1074
+
1075
+ if (variant.padding === undefined) return;
1076
+
1077
+ if (
1078
+ typeof variant.padding !== "object" ||
1079
+ variant.padding === null ||
1080
+ Array.isArray(variant.padding)
1081
+ ) {
1082
+ throwConfigError(
1083
+ `Logo ${mode} padding must be an object with optional 'top' and 'bottom'.`,
1084
+ [...currentPath, "padding"],
1085
+ );
1086
+ }
1087
+
1088
+ const paddingKeys = Object.keys(variant.padding);
1089
+ for (const key of paddingKeys) {
1090
+ if (key !== "top" && key !== "bottom") {
800
1091
  throwConfigError(
801
- `Logo dark file not found. Expected: ${normalizedPath} (relative to content/docs folder)`,
802
- ["logo", "dark"]
1092
+ `Logo ${mode} padding only supports 'top' and 'bottom'.`,
1093
+ [...currentPath, "padding", key],
803
1094
  );
804
1095
  }
805
1096
  }
806
1097
 
1098
+ validateLogoPaddingValue(
1099
+ variant.padding.top,
1100
+ [...currentPath, "padding", "top"],
1101
+ `Logo ${mode} padding top`,
1102
+ );
1103
+ validateLogoPaddingValue(
1104
+ variant.padding.bottom,
1105
+ [...currentPath, "padding", "bottom"],
1106
+ `Logo ${mode} padding bottom`,
1107
+ );
1108
+ }
1109
+
1110
+ function validateLogo(logo: DocsConfig["logo"]) {
1111
+ // Logo is optional, so if it's undefined, we're done
1112
+ if (logo === undefined) return;
1113
+
1114
+ // If logo is provided, it must be an object
1115
+ checkType(logo, "object", ["logo"], "Logo configuration");
1116
+
1117
+ validateLogoVariant(logo.light, ["logo", "light"], "light");
1118
+ validateLogoVariant(logo.dark, ["logo", "dark"], "dark");
1119
+
807
1120
  // Validate 'href' if provided
808
1121
  if (logo.href !== undefined) {
809
1122
  checkType(logo.href, "string", ["logo", "href"], "Logo href");
@@ -827,14 +1140,32 @@ function validateLogo(logo: DocsConfig["logo"]) {
827
1140
  if (!isUrl && !isInternalPath) {
828
1141
  throwConfigError(
829
1142
  "Logo href must be either a valid URL (http:// or https://) or an internal path (starting with /)",
830
- ["logo", "href"]
1143
+ ["logo", "href"],
831
1144
  );
832
1145
  }
833
1146
  }
1147
+
1148
+ if (logo.pill !== undefined) {
1149
+ if (typeof logo.pill === "string") {
1150
+ if (logo.pill.trim() === "") {
1151
+ throwConfigError(
1152
+ "Logo pill text cannot be an empty string. Use false to hide the pill.",
1153
+ ["logo", "pill"],
1154
+ );
1155
+ }
1156
+ } else if (logo.pill !== false) {
1157
+ throwConfigError("Logo pill must be a string or false.", ["logo", "pill"]);
1158
+ }
1159
+ }
1160
+
834
1161
  }
835
1162
 
836
- function validateHome(home: DocsConfig["home"]) {
837
- checkType(home, "string", ["home"], "Home path");
1163
+ function validateHome(home: DocsConfig["home"]): string | undefined {
1164
+ if (home === undefined) return undefined;
1165
+
1166
+ const normalizedHome = normalizeDocsPagePath(home, ["home"], "Home path");
1167
+ validateFileExistence(normalizedHome, ["home"]);
1168
+ return normalizedHome;
838
1169
  }
839
1170
 
840
1171
  function validateNavbar(navbar: DocsConfig["navbar"]) {
@@ -868,40 +1199,142 @@ function validateNavbar(navbar: DocsConfig["navbar"]) {
868
1199
  }
869
1200
  }
870
1201
 
1202
+ function validateFooter(footer: DocsConfig["footer"]) {
1203
+ if (footer === undefined) return;
1204
+
1205
+ checkType(footer, "object", ["footer"], "Footer configuration");
1206
+
1207
+ // Validate socials
1208
+ if (footer.socials !== undefined) {
1209
+ checkType(
1210
+ footer.socials,
1211
+ "object",
1212
+ ["footer", "socials"],
1213
+ "Footer socials",
1214
+ );
1215
+ const validSocials = [
1216
+ "x",
1217
+ "website",
1218
+ "facebook",
1219
+ "youtube",
1220
+ "discord",
1221
+ "slack",
1222
+ "github",
1223
+ "linkedin",
1224
+ "instagram",
1225
+ "hacker-news",
1226
+ "medium",
1227
+ "telegram",
1228
+ "bluesky",
1229
+ "threads",
1230
+ "reddit",
1231
+ "podcast",
1232
+ ];
1233
+ for (const [key, value] of Object.entries(footer.socials)) {
1234
+ if (!validSocials.includes(key)) {
1235
+ throwConfigError(
1236
+ `Invalid social platform: ${key}. Valid options are: ${validSocials.join(
1237
+ ", ",
1238
+ )}`,
1239
+ ["footer", "socials", key],
1240
+ );
1241
+ }
1242
+ checkType(
1243
+ value,
1244
+ "string",
1245
+ ["footer", "socials", key],
1246
+ `Social link for ${key}`,
1247
+ );
1248
+ if (!isUrl(value as string)) {
1249
+ throwConfigError(`Social link for ${key} must be a valid URL.`, [
1250
+ "footer",
1251
+ "socials",
1252
+ key,
1253
+ ]);
1254
+ }
1255
+ }
1256
+ }
1257
+
1258
+ // Validate links
1259
+ if (footer.links !== undefined) {
1260
+ checkType(footer.links, "array", ["footer", "links"], "Footer links");
1261
+ footer.links.forEach((link: any, i: number) => {
1262
+ checkType(link, "object", ["footer", "links", i], "Footer link");
1263
+
1264
+ if (typeof link.text !== "string") {
1265
+ throwConfigError("Footer link must have a 'text' property.", [
1266
+ "footer",
1267
+ "links",
1268
+ i,
1269
+ "text",
1270
+ ]);
1271
+ }
1272
+
1273
+ if (typeof link.href !== "string") {
1274
+ throwConfigError("Footer link must have an 'href' property.", [
1275
+ "footer",
1276
+ "links",
1277
+ i,
1278
+ "href",
1279
+ ]);
1280
+ }
1281
+
1282
+ const trimmedHref = link.href.trim();
1283
+ const isExternal =
1284
+ trimmedHref.startsWith("http://") || trimmedHref.startsWith("https://");
1285
+ const isInternal = trimmedHref.startsWith("/");
1286
+
1287
+ if (!isExternal && !isInternal) {
1288
+ throwConfigError(
1289
+ "Footer link href must be either a valid URL (http:// or https://) or an internal path (starting with /)",
1290
+ ["footer", "links", i, "href"],
1291
+ );
1292
+ }
1293
+ });
1294
+ }
1295
+ }
1296
+
871
1297
  async function validateNavigation(navigation: DocsConfig["navigation"]) {
872
1298
  checkType(navigation, "object", ["navigation"], "Navigation");
873
1299
 
874
1300
  const keys = Object.keys(navigation);
875
- const validKeys = ["pages", "groups", "menu", "openapi"];
1301
+ const validKeys = ["pages", "menu", "openapi"];
876
1302
  const navKeys = keys.filter((key) => validKeys.includes(key));
877
1303
 
878
1304
  if (navKeys.length !== 1) {
879
1305
  throwConfigError(
880
1306
  `Navigation must contain exactly one top-level item (${validKeys.join(
881
- ", "
1307
+ ", ",
882
1308
  )}). Found ${navKeys.length}.`,
883
- ["navigation"]
1309
+ ["navigation"],
884
1310
  );
885
1311
  }
886
1312
 
887
1313
  const navKey = navKeys[0];
888
1314
  const navValue = (navigation as any)[navKey];
889
1315
 
890
- // Handle "menu" as an object, "pages" and "groups" as arrays
1316
+ // Handle "menu" as an object, "pages" as an array
891
1317
  if (navKey === "menu") {
892
1318
  await validateNavMenu(navValue, ["navigation", "menu"]);
893
1319
  } else {
894
- // Validate the container itself is an array for pages/groups
1320
+ // Validate the container itself is an array for pages
895
1321
  checkType(
896
1322
  navValue,
897
1323
  "array",
898
1324
  ["navigation", navKey],
899
- `Navigation container '${navKey}'`
1325
+ `Navigation container '${navKey}'`,
900
1326
  );
901
1327
 
902
1328
  // Route to Recursive Structural Validation
903
1329
  navValue.forEach((item: NavigationItem, i: number) => {
904
- validateNavigationNode(item, ["navigation", navKey, i]);
1330
+ const itemPath = ["navigation", navKey, i] as Path;
1331
+ if (typeof item === "string") {
1332
+ const normalizedPagePath = normalizeDocsPagePath(item, itemPath);
1333
+ navValue[i] = normalizedPagePath;
1334
+ validateFileExistence(normalizedPagePath, itemPath);
1335
+ } else {
1336
+ validateNavigationNode(item, itemPath);
1337
+ }
905
1338
  });
906
1339
  }
907
1340
  }
@@ -912,9 +1345,34 @@ async function validateConfig(config: any): Promise<DocsConfig> {
912
1345
  // Execute top-level checks sequentially
913
1346
  validateTitle(config.title);
914
1347
  validateLogo(config.logo);
915
- validateHome(config.home);
916
1348
  validateNavbar(config.navbar);
1349
+ validateFooter(config.footer);
917
1350
  await validateNavigation(config.navigation);
1351
+ config.home = validateHome(config.home);
1352
+
1353
+ if (config.home === undefined) {
1354
+ const fallbackHome = getFirstPagePathFromNavigation(config.navigation);
1355
+ if (!fallbackHome) {
1356
+ throwConfigError(
1357
+ "Home is undefined and no documentation page exists in navigation to use as fallback.",
1358
+ ["home"],
1359
+ );
1360
+ }
1361
+ config.home = fallbackHome;
1362
+ }
1363
+
1364
+ // --- 4. Validate Playground ---
1365
+ if (config.playground !== undefined) {
1366
+ checkType(config.playground, "object", ["playground"], "Playground");
1367
+ if (config.playground.proxy !== undefined) {
1368
+ checkType(
1369
+ config.playground.proxy,
1370
+ "boolean",
1371
+ ["playground", "proxy"],
1372
+ "Proxy",
1373
+ );
1374
+ }
1375
+ }
918
1376
 
919
1377
  return config as DocsConfig;
920
1378
  }
@@ -926,7 +1384,7 @@ export async function getConfig(): Promise<DocsConfig> {
926
1384
  // 1. Check if docs.json exists
927
1385
  if (!fs.existsSync(CONFIG_PATH)) {
928
1386
  throw new Error(
929
- "[USER_ERROR]: Invalid docs.json: docs.json missing at root of documentation repo."
1387
+ "[USER_ERROR]: Invalid docs.json: `docs.json` missing at root of documentation repo.",
930
1388
  );
931
1389
  }
932
1390
 
@@ -947,7 +1405,7 @@ export async function getConfig(): Promise<DocsConfig> {
947
1405
  throw new Error(
948
1406
  `[USER_ERROR]: Invalid docs.json: Invalid JSON syntax: ${
949
1407
  e instanceof Error ? e.message : e
950
- }`
1408
+ }`,
951
1409
  );
952
1410
  }
953
1411
  // ---
@@ -961,7 +1419,7 @@ export async function getConfig(): Promise<DocsConfig> {
961
1419
  throw new Error(
962
1420
  `[USER_ERROR]: Invalid docs.json: ${
963
1421
  error instanceof Error ? error.message : error
964
- }`
1422
+ }`,
965
1423
  );
966
1424
  }
967
1425
  })();
@@ -973,32 +1431,7 @@ export async function getConfig(): Promise<DocsConfig> {
973
1431
  function validateComponentUsage(content: string): void {
974
1432
  // Remove frontmatter before checking
975
1433
  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];
1434
+ const allowedComponentSet = new Set(AVAILABLE_COMPONENTS);
1002
1435
 
1003
1436
  // Remove code blocks, inline code, and JSX string expressions to avoid false positives
1004
1437
  const contentWithoutCode = contentWithoutFrontmatter
@@ -1016,7 +1449,7 @@ function validateComponentUsage(content: string): void {
1016
1449
 
1017
1450
  while ((match = componentRegex.exec(contentWithoutCode)) !== null) {
1018
1451
  const componentName = match[1];
1019
- if (!allowedComponents.includes(componentName)) {
1452
+ if (!allowedComponentSet.has(componentName)) {
1020
1453
  // Avoid duplicate entries
1021
1454
  if (!unknownComponents.includes(componentName)) {
1022
1455
  unknownComponents.push(componentName);
@@ -1026,10 +1459,15 @@ function validateComponentUsage(content: string): void {
1026
1459
 
1027
1460
  if (unknownComponents.length > 0) {
1028
1461
  const componentList = unknownComponents.map((c) => `<${c}>`).join(", ");
1462
+ const visibleComponents = AVAILABLE_COMPONENTS.filter(
1463
+ (component) => !INTERNAL_ONLY_COMPONENTS.has(component),
1464
+ );
1029
1465
  throw new Error(
1030
1466
  `Unknown component(s): ${componentList}. ` +
1031
- `Available components are: ${AVAILABLE_COMPONENTS.join(", ")}. ` +
1032
- `If writing ABOUT a component, use backticks: \`<ComponentName>\` or JSX strings: {'<ComponentName />'}`
1467
+ `Available components are: ${visibleComponents.join(", ")}. ` +
1468
+ "If writing ABOUT a component, use literal backticks: " +
1469
+ "\`<ComponentName>\` or a JSX string: " +
1470
+ "\`{'<ComponentName />'}\`.",
1033
1471
  );
1034
1472
  }
1035
1473
  }
@@ -1073,7 +1511,7 @@ export async function validateMdxContent() {
1073
1511
  const pathStr = issue.path.join(".");
1074
1512
  // Throw clean error
1075
1513
  throw new Error(
1076
- `Frontmatter validation failed: ${issue.message} (at: ${pathStr})`
1514
+ `Frontmatter validation failed: ${issue.message} (at: ${pathStr})`,
1077
1515
  );
1078
1516
  }
1079
1517
  }
@@ -1090,7 +1528,7 @@ export async function validateMdxContent() {
1090
1528
 
1091
1529
  // Throw clean error
1092
1530
  throw new Error(
1093
- `[USER_ERROR]: Invalid MDX in ${relativePath}${location} -> ${reason}`
1531
+ `[USER_ERROR]: Invalid MDX in ${relativePath}${location} -> ${reason}`,
1094
1532
  );
1095
1533
  }
1096
1534
  }