radiant-docs 0.1.56 → 0.1.57

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 (33) hide show
  1. package/dist/index.js +3 -76
  2. package/package.json +2 -4
  3. package/template/astro.config.mjs +16 -26
  4. package/template/package-lock.json +18 -0
  5. package/template/package.json +1 -1
  6. package/template/src/components/Footer.astro +13 -4
  7. package/template/src/components/Header.astro +26 -6
  8. package/template/src/components/SidebarLink.astro +3 -2
  9. package/template/src/components/SidebarSubgroup.astro +14 -13
  10. package/template/src/components/sidebar/SidebarEndpointLink.astro +13 -3
  11. package/template/src/components/sidebar/SidebarOpenApi.astro +2 -0
  12. package/template/src/components/sidebar/SidebarOpenApiPageLink.astro +1 -0
  13. package/template/src/components/user/Accordion.astro +0 -13
  14. package/template/src/components/user/Callout.astro +0 -29
  15. package/template/src/components/user/Card.astro +31 -204
  16. package/template/src/components/user/CardGradient.astro +8 -1
  17. package/template/src/components/user/Column.astro +0 -17
  18. package/template/src/components/user/Columns.astro +4 -153
  19. package/template/src/components/user/Image.astro +0 -28
  20. package/template/src/components/user/Step.astro +0 -10
  21. package/template/src/components/user/Tab.astro +0 -12
  22. package/template/src/components/user/Tabs.astro +2 -9
  23. package/template/src/content.config.ts +1 -1
  24. package/template/src/lib/code/code-block.ts +1 -1
  25. package/template/src/lib/mdx/remark-code-block-component.ts +1 -20
  26. package/template/src/lib/mdx/remark-resolve-internal-links.ts +150 -204
  27. package/template/src/lib/routes.ts +150 -29
  28. package/template/src/lib/utils.ts +127 -12
  29. package/template/src/lib/validation.ts +5 -2826
  30. package/template/src/pages/[...slug].astro +16 -0
  31. package/template/src/lib/code/shiki-theme-config.ts +0 -16
  32. package/template/src/lib/component-error.ts +0 -202
  33. package/template/src/lib/frontmatter-schema.ts +0 -10
@@ -1,2829 +1,8 @@
1
- import fs from "node:fs";
2
1
  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
- import {
10
- DEFAULT_SHIKI_DARK_THEME,
11
- DEFAULT_SHIKI_LIGHT_THEME,
12
- SHIKI_BUNDLED_THEME_NAMES,
13
- isBundledShikiThemeName,
14
- } from "./code/shiki-theme-config";
2
+ import { configureDocsValidator } from "radiant-docs-validator";
15
3
 
16
- // --- Configuration Constants ---
17
- const CWD = process.cwd();
18
- const DOCS_DIR = path.join(CWD, "src/content/docs");
19
- const CONFIG_PATH = path.join(DOCS_DIR, "docs.json");
4
+ configureDocsValidator({
5
+ docsRoot: path.join(process.cwd(), "src/content/docs"),
6
+ });
20
7
 
21
- // Cache for icon sets (key: prefix, value: Set of icon names)
22
- const iconSets = new Map<string, Set<string>>();
23
-
24
- // Helper function to check if a string is a URL
25
- function isUrl(str: string): boolean {
26
- try {
27
- const url = new URL(str);
28
- return url.protocol === "http:" || url.protocol === "https:";
29
- } catch {
30
- return false;
31
- }
32
- }
33
-
34
- function getIconSet(prefix: string): Set<string> | null {
35
- if (iconSets.has(prefix)) return iconSets.get(prefix)!;
36
-
37
- try {
38
- const iconsPath = path.join(
39
- CWD,
40
- `node_modules/@iconify-json/${prefix}/icons.json`,
41
- );
42
- if (!fs.existsSync(iconsPath)) {
43
- return null;
44
- }
45
- const iconsData = JSON.parse(fs.readFileSync(iconsPath, "utf-8"));
46
- const set = new Set(Object.keys(iconsData.icons));
47
- iconSets.set(prefix, set);
48
- return set;
49
- } catch (error) {
50
- console.error(`Failed to load icon set for prefix "${prefix}":`, error);
51
- return null;
52
- }
53
- }
54
-
55
- function validateIcon(icon: any, currentPath: Path): void {
56
- if (icon === undefined || icon === null) return;
57
-
58
- if (typeof icon !== "string") {
59
- throwConfigError("Icon must be a string.", currentPath);
60
- }
61
-
62
- // 1. Handle remote URLs
63
- if (isUrl(icon)) {
64
- return;
65
- }
66
-
67
- // 2. Handle Iconify icons (prefix:name)
68
- if (icon.includes(":")) {
69
- const parts = icon.split(":");
70
-
71
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
72
- throwConfigError(
73
- `Invalid library icon format: "${icon}". Icons must follow the "library-prefix:name" format (e.g., "lucide:home") or be a local path.`,
74
- currentPath,
75
- );
76
- }
77
-
78
- const [prefix, name] = parts;
79
- const icons = getIconSet(prefix);
80
-
81
- if (icons) {
82
- if (!icons.has(name)) {
83
- throwConfigError(
84
- `Invalid icon name: "${name}" for library "${prefix}". Is this a typo?`,
85
- currentPath,
86
- );
87
- }
88
- } else {
89
- throwConfigError(
90
- `Invalid icon library: "${prefix}". Is this package installed in @iconify-json?`,
91
- currentPath,
92
- );
93
- }
94
- return;
95
- }
96
-
97
- // 3. Handle local icons
98
- // Check if it's a file path (must exist in DOCS_DIR)
99
- const localRelativePath = icon.startsWith("/") ? icon.slice(1) : icon;
100
- const localPath = path.join(DOCS_DIR, localRelativePath);
101
-
102
- if (!fs.existsSync(localPath)) {
103
- throwConfigError(
104
- `Icon not found: "${icon}". Local icons must exist in your repository. Did you mean to use an library icon like "lucide:home"?`,
105
- currentPath,
106
- );
107
- }
108
- }
109
-
110
- // Define the list of available user components for MDX
111
- const AVAILABLE_COMPONENTS = [
112
- "Callout",
113
- "Tabs",
114
- "Tab",
115
- "Steps",
116
- "Step",
117
- "Accordion",
118
- "AccordionGroup",
119
- "Card",
120
- "Column",
121
- "Columns",
122
- "Image",
123
- "CodeGroup",
124
- "ComponentPreview",
125
- ];
126
-
127
- // Internal components can be valid in MDX while remaining hidden from
128
- // user-facing error guidance.
129
- const INTERNAL_ONLY_COMPONENTS = new Set(["ComponentPreview"]);
130
-
131
- export type NavPage = {
132
- page: string;
133
- icon?: string | null;
134
- tag?: NavTag;
135
- title?: string;
136
- };
137
- export type NavOpenApiPageRef = {
138
- source: string;
139
- endpoint: string;
140
- };
141
- export type NavOpenApiPage = {
142
- openapi: NavOpenApiPageRef;
143
- title?: string;
144
- tag?: NavTag;
145
- };
146
- export type NavGroup = {
147
- group: string;
148
- pages: (string | NavPage | NavGroup | NavOpenApiPage)[];
149
- icon?: string | null;
150
- expanded?: boolean; // need to add this logic
151
- tag?: NavTag;
152
- };
153
- export type NavOpenApi = {
154
- source: string;
155
- include?: string[];
156
- exclude?: string[];
157
- };
158
- export type NavigationItem = {
159
- pages?: (string | NavPage | NavGroup | NavOpenApiPage)[];
160
- menu?: NavMenu;
161
- openapi?: string | NavOpenApi;
162
- };
163
-
164
- export type NavMenuItem = {
165
- label: string;
166
- submenu: Omit<NavigationItem, "menu">;
167
- icon?: string | null;
168
- };
169
- export type NavMenu = {
170
- type?: "dropdown" | "segmented";
171
- label?: string;
172
- items: NavMenuItem[];
173
- };
174
- export type NavbarItem = {
175
- text: string;
176
- href: string;
177
- icon?: string | null;
178
- color?: string | ThemeColorByMode;
179
- };
180
- export type HiddenPageRoute = {
181
- filePath: string;
182
- href: string;
183
- };
184
- type InternalPageHrefResolution = HiddenPageRoute & {
185
- linkHref: string;
186
- };
187
- export type LogoVariant =
188
- | string
189
- | {
190
- image: string;
191
- padding?: {
192
- top?: number;
193
- bottom?: number;
194
- };
195
- };
196
- export type Logo = {
197
- light?: LogoVariant;
198
- dark?: LogoVariant;
199
- href?: string;
200
- pill?: string | false;
201
- };
202
- export type ThemeColorByMode = {
203
- light?: string;
204
- dark?: string;
205
- };
206
- export type NavTag =
207
- | string
208
- | {
209
- text: string;
210
- color?: string | ThemeColorByMode;
211
- };
212
- export const BASE_COLOR_OPTIONS = [
213
- "slate",
214
- "gray",
215
- "zinc",
216
- "neutral",
217
- "stone",
218
- "taupe",
219
- "mauve",
220
- "mist",
221
- "olive",
222
- ] as const;
223
- export type BaseColorOption = (typeof BASE_COLOR_OPTIONS)[number];
224
- export type BaseColorByMode = {
225
- light: BaseColorOption;
226
- dark: BaseColorOption;
227
- };
228
- export const DEFAULT_THEME_COLOR_LIGHT = "#171717";
229
- export const DEFAULT_THEME_COLOR_DARK = "#f5f5f5";
230
- export type CardCoverTheme = {
231
- colors?: string[];
232
- colorSeed?: string;
233
- };
234
- export type CardButtonTheme = {
235
- color?: string | ThemeColorByMode;
236
- };
237
- export type CardTheme = {
238
- cover?: CardCoverTheme;
239
- button?: CardButtonTheme;
240
- };
241
- export type CodeSyntaxThemeConfig =
242
- | string
243
- | {
244
- light?: string;
245
- dark?: string;
246
- };
247
- export type CodeTheme = {
248
- syntaxTheme?: CodeSyntaxThemeConfig;
249
- };
250
- export type TagTheme = {
251
- color?: string | ThemeColorByMode;
252
- };
253
- export type DocsTheme = {
254
- baseColor?: BaseColorOption | BaseColorByMode;
255
- themeColor?: string | ThemeColorByMode;
256
- card?: CardTheme;
257
- code?: CodeTheme;
258
- tag?: TagTheme;
259
- };
260
- export type AssistantIcon = {
261
- src?: string;
262
- };
263
- export type AssistantButtonSize = "small" | "default";
264
- export type AssistantButtonConfig = {
265
- size?: AssistantButtonSize;
266
- color?: string | ThemeColorByMode;
267
- };
268
- export type AssistantNavbarButtonConfig = {
269
- enabled?: boolean;
270
- text?: string;
271
- color?: string | ThemeColorByMode;
272
- };
273
- export type AssistantConfig = {
274
- button?: AssistantButtonConfig;
275
- navbarButton?: AssistantNavbarButtonConfig;
276
- heading?: string;
277
- questions?: string[];
278
- icon?: AssistantIcon;
279
- };
280
- export type DocsConfig = {
281
- title: string;
282
- logo?: Logo;
283
- theme?: DocsTheme;
284
- assistant?: AssistantConfig;
285
- home?: string;
286
- navigation: NavigationItem;
287
- navbar?: {
288
- blur?: boolean;
289
- primary?: NavbarItem;
290
- secondary?: NavbarItem;
291
- links?: NavbarItem[];
292
- };
293
- playground?: {
294
- proxy?: boolean;
295
- };
296
- footer?: Footer;
297
- hiddenPageRoutes?: HiddenPageRoute[];
298
- };
299
-
300
- export type SocialPlatform =
301
- | "x"
302
- | "website"
303
- | "facebook"
304
- | "youtube"
305
- | "discord"
306
- | "slack"
307
- | "github"
308
- | "linkedin"
309
- | "instagram"
310
- | "hacker-news"
311
- | "medium"
312
- | "telegram"
313
- | "bluesky"
314
- | "threads"
315
- | "reddit"
316
- | "podcast";
317
- export type FooterLink = {
318
- text: string;
319
- href: string;
320
- };
321
- export type Footer = {
322
- socials?: Partial<Record<SocialPlatform, string>>;
323
- links?: FooterLink[];
324
- };
325
- type Path = (string | number)[];
326
-
327
- // --- 1. Error Utility ---
328
-
329
- const throwConfigError = (message: string, currentPath: Path): never => {
330
- const location =
331
- currentPath.length > 0 ? ` (at: ${currentPath.join(".")})` : "";
332
- throw new Error(`${message}${location}\n`);
333
- };
334
-
335
- // --- 2. Core Validation Logic (Recursive and Structural) ---
336
-
337
- // Helper for basic type checks, allowing undefined for optional keys
338
- function checkType(
339
- value: any,
340
- type: "string" | "boolean" | "array" | "object",
341
- currentPath: Path,
342
- label: string,
343
- ): void {
344
- if (value === undefined || value === null) return;
345
-
346
- if (type === "array") {
347
- if (!Array.isArray(value))
348
- throwConfigError(`${label} must be an array.`, currentPath);
349
- } else if (type === "object") {
350
- if (typeof value !== "object" || value === null)
351
- throwConfigError(`${label} must be an object.`, currentPath);
352
- } else {
353
- if (typeof value !== type)
354
- throwConfigError(`${label} must be a ${type}.`, currentPath);
355
- }
356
- }
357
-
358
- function normalizeHexColor(
359
- value: unknown,
360
- currentPath: Path,
361
- label: string,
362
- ): string {
363
- checkType(value, "string", currentPath, label);
364
- if (typeof value !== "string") {
365
- throwConfigError(`${label} must be a string.`, currentPath);
366
- }
367
-
368
- const trimmedValue = value.trim();
369
- if (trimmedValue.length === 0) {
370
- throwConfigError(`${label} cannot be empty.`, currentPath);
371
- }
372
-
373
- const normalizedValue = trimmedValue.startsWith("#")
374
- ? trimmedValue
375
- : `#${trimmedValue}`;
376
- if (
377
- !/^#(?:[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/.test(
378
- normalizedValue,
379
- )
380
- ) {
381
- throwConfigError(
382
- `${label} must be a valid hex color (for example: #1d4ed8).`,
383
- currentPath,
384
- );
385
- }
386
-
387
- return normalizedValue.toLowerCase();
388
- }
389
-
390
- function normalizeThemeColorConfig(
391
- value: unknown,
392
- currentPath: Path,
393
- label: string,
394
- ): string | ThemeColorByMode {
395
- if (typeof value === "string") {
396
- return normalizeHexColor(value, currentPath, label);
397
- }
398
-
399
- checkType(value, "object", currentPath, label);
400
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
401
- throwConfigError(
402
- `${label} must be a string or an object with light/dark values.`,
403
- currentPath,
404
- );
405
- }
406
-
407
- const colorByMode = value as Record<string, unknown>;
408
- const allowedKeys = new Set(["light", "dark"]);
409
- for (const key of Object.keys(colorByMode)) {
410
- if (!allowedKeys.has(key)) {
411
- throwConfigError(`${label} object only supports 'light' and 'dark'.`, [
412
- ...currentPath,
413
- key,
414
- ]);
415
- }
416
- }
417
-
418
- const light =
419
- colorByMode.light !== undefined
420
- ? normalizeHexColor(colorByMode.light, [...currentPath, "light"], label)
421
- : undefined;
422
- const dark =
423
- colorByMode.dark !== undefined
424
- ? normalizeHexColor(colorByMode.dark, [...currentPath, "dark"], label)
425
- : undefined;
426
-
427
- if (light === undefined && dark === undefined) {
428
- throwConfigError(
429
- `${label} object must include 'light', 'dark', or both.`,
430
- currentPath,
431
- );
432
- }
433
-
434
- return {
435
- ...(light !== undefined ? { light } : {}),
436
- ...(dark !== undefined ? { dark } : {}),
437
- };
438
- }
439
-
440
- function normalizeNavTagConfig(
441
- value: unknown,
442
- currentPath: Path,
443
- label: string,
444
- ): NavTag | undefined {
445
- if (value === undefined || value === null) return undefined;
446
-
447
- if (typeof value === "string") {
448
- const trimmedText = value.trim();
449
- if (trimmedText.length === 0) {
450
- throwConfigError(`${label} cannot be empty.`, currentPath);
451
- }
452
- return trimmedText;
453
- }
454
-
455
- checkType(value, "object", currentPath, label);
456
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
457
- throwConfigError(
458
- `${label} must be a string or an object with text and optional color.`,
459
- currentPath,
460
- );
461
- }
462
-
463
- const tagConfig = value as Record<string, unknown>;
464
- const allowedKeys = new Set(["text", "color"]);
465
- for (const key of Object.keys(tagConfig)) {
466
- if (!allowedKeys.has(key)) {
467
- throwConfigError(`${label} object only supports 'text' and 'color'.`, [
468
- ...currentPath,
469
- key,
470
- ]);
471
- }
472
- }
473
-
474
- checkType(tagConfig.text, "string", [...currentPath, "text"], `${label} text`);
475
- if (typeof tagConfig.text !== "string") {
476
- throwConfigError(`${label} text must be a string.`, [
477
- ...currentPath,
478
- "text",
479
- ]);
480
- }
481
-
482
- const trimmedText = tagConfig.text.trim();
483
- if (trimmedText.length === 0) {
484
- throwConfigError(`${label} text cannot be empty.`, [
485
- ...currentPath,
486
- "text",
487
- ]);
488
- }
489
-
490
- const color =
491
- tagConfig.color !== undefined
492
- ? normalizeThemeColorConfig(
493
- tagConfig.color,
494
- [...currentPath, "color"],
495
- `${label} color`,
496
- )
497
- : undefined;
498
-
499
- return {
500
- text: trimmedText,
501
- ...(color !== undefined ? { color } : {}),
502
- };
503
- }
504
-
505
- function normalizeHexColorArray(
506
- value: unknown,
507
- currentPath: Path,
508
- label: string,
509
- ): string[] {
510
- checkType(value, "array", currentPath, label);
511
- if (!Array.isArray(value)) {
512
- throwConfigError(`${label} must be an array.`, currentPath);
513
- }
514
-
515
- if (value.length < 1 || value.length > 4) {
516
- throwConfigError(`${label} must include 1 to 4 colors.`, currentPath);
517
- }
518
-
519
- return value.map((color, index) =>
520
- normalizeHexColor(color, [...currentPath, index], `${label} ${index + 1}`),
521
- );
522
- }
523
-
524
- function normalizeSeedValue(
525
- value: unknown,
526
- currentPath: Path,
527
- label: string,
528
- ): string {
529
- checkType(value, "string", currentPath, label);
530
- if (typeof value !== "string") {
531
- throwConfigError(`${label} must be a string.`, currentPath);
532
- }
533
-
534
- const trimmedValue = value.trim();
535
- if (trimmedValue.length === 0) {
536
- throwConfigError(`${label} cannot be empty.`, currentPath);
537
- }
538
-
539
- return trimmedValue;
540
- }
541
-
542
- function validateFileExistence(filePath: string, currentPath: Path): void {
543
- // Assuming relative path from DOCS_DIR and .mdx extension
544
- const fullPath = path.join(DOCS_DIR, `${filePath}.mdx`);
545
-
546
- if (!fs.existsSync(fullPath)) {
547
- throwConfigError(
548
- `Referenced file not found. Expected: ${filePath}`,
549
- currentPath,
550
- );
551
- }
552
- }
553
-
554
- function normalizeDocsPagePath(
555
- value: string,
556
- currentPath: Path,
557
- label: string = "Page path",
558
- ): string {
559
- checkType(value, "string", currentPath, label);
560
-
561
- const trimmedPath = value.trim();
562
- if (trimmedPath === "") {
563
- throwConfigError(`${label} cannot be an empty string`, currentPath);
564
- }
565
-
566
- if (isUrl(trimmedPath)) {
567
- throwConfigError(
568
- `${label} must reference a documentation page path, not a URL`,
569
- currentPath,
570
- );
571
- }
572
-
573
- const normalizedPath = trimmedPath.replace(/^\/+/, "").replace(/\/+$/, "");
574
-
575
- if (normalizedPath === "") {
576
- throwConfigError(`${label} cannot be '/'`, currentPath);
577
- }
578
-
579
- return normalizedPath;
580
- }
581
-
582
- function splitHrefPathAndSuffix(href: string): {
583
- pathname: string;
584
- suffix: string;
585
- } {
586
- const match = href.match(/^([^?#]*)(.*)$/);
587
- return {
588
- pathname: match?.[1] ?? href,
589
- suffix: match?.[2] ?? "",
590
- };
591
- }
592
-
593
- function normalizeInternalPageHref(
594
- href: string,
595
- currentPath: Path,
596
- label: string,
597
- ): InternalPageHrefResolution | null {
598
- const trimmedHref = href.trim();
599
- if (trimmedHref === "") {
600
- throwConfigError(`${label} cannot be an empty string`, currentPath);
601
- }
602
-
603
- if (isUrl(trimmedHref)) {
604
- return null;
605
- }
606
-
607
- if (!trimmedHref.startsWith("/")) {
608
- throwConfigError(
609
- `${label} must be either a valid URL (http:// or https://) or an internal path (starting with /)`,
610
- currentPath,
611
- );
612
- }
613
-
614
- const { pathname, suffix } = splitHrefPathAndSuffix(trimmedHref);
615
- const normalizedPathname = pathname.replace(/\/{2,}/g, "/");
616
- if (normalizedPathname === "/" || normalizedPathname === "") {
617
- return null;
618
- }
619
-
620
- const filePath = normalizeDocsPagePath(
621
- normalizedPathname,
622
- currentPath,
623
- label,
624
- );
625
- validateFileExistence(filePath, currentPath);
626
-
627
- return {
628
- filePath,
629
- href: `/${filePath}`,
630
- linkHref: `/${filePath}${suffix}`,
631
- };
632
- }
633
-
634
- // Cache for OpenAPI specs (key: filePathOrUrl, value: parsed spec)
635
- const openApiSpecCache = new Map<string, any>();
636
-
637
- // Helper function to load and parse OpenAPI spec
638
- export async function loadOpenApiSpec(filePathOrUrl: string): Promise<any> {
639
- // Check cache first
640
- if (openApiSpecCache.has(filePathOrUrl)) {
641
- return openApiSpecCache.get(filePathOrUrl);
642
- }
643
-
644
- const isUrlPath = isUrl(filePathOrUrl);
645
-
646
- let fileContent: string;
647
-
648
- if (isUrlPath) {
649
- // Fetch from URL
650
- try {
651
- const response = await fetch(filePathOrUrl);
652
- if (!response.ok) {
653
- throw new Error(
654
- `Failed to fetch OpenAPI spec: ${response.status} ${response.statusText}`,
655
- );
656
- }
657
- fileContent = await response.text();
658
- } catch (error) {
659
- throw new Error(
660
- `Failed to fetch OpenAPI spec from URL: ${
661
- error instanceof Error ? error.message : String(error)
662
- }`,
663
- );
664
- }
665
- } else {
666
- // Read from local file
667
- const fullPath = path.join(DOCS_DIR, filePathOrUrl);
668
- fileContent = fs.readFileSync(fullPath, "utf-8");
669
- }
670
-
671
- // Detect HTML content (common mistake: URL returns HTML page instead of spec)
672
- const trimmedContent = fileContent.trim();
673
- if (
674
- trimmedContent.startsWith("<!DOCTYPE") ||
675
- trimmedContent.startsWith("<html")
676
- ) {
677
- throw new Error(
678
- "The URL does not return a valid OpenAPI specification. The URL appears to return HTML instead of JSON or YAML.",
679
- );
680
- }
681
-
682
- // Determine format and parse
683
- let parsedSpec: any;
684
- try {
685
- if (
686
- filePathOrUrl.endsWith(".json") ||
687
- (isUrlPath && filePathOrUrl.includes(".json"))
688
- ) {
689
- parsedSpec = JSON.parse(fileContent);
690
- } else {
691
- const yaml = await import("yaml");
692
- parsedSpec = yaml.parse(fileContent);
693
- }
694
- } catch (parseError) {
695
- if (parseError instanceof SyntaxError) {
696
- throw new Error(
697
- `The URL does not return a valid OpenAPI specification. Failed to parse as JSON or YAML: ${parseError.message}`,
698
- );
699
- }
700
- throw parseError;
701
- }
702
-
703
- // Cache the parsed spec
704
- openApiSpecCache.set(filePathOrUrl, parsedSpec);
705
-
706
- return parsedSpec;
707
- }
708
-
709
- async function validateOpenApiFile(filePathOrUrl: string, currentPath: Path) {
710
- const isUrlPath = isUrl(filePathOrUrl);
711
-
712
- if (!isUrlPath) {
713
- // For local files, validate extension and existence
714
- const validExtensions = [".json", ".yaml", ".yml"];
715
- const hasValidExtension = validExtensions.some((ext) =>
716
- filePathOrUrl.toLowerCase().endsWith(ext),
717
- );
718
-
719
- if (!hasValidExtension) {
720
- throwConfigError(
721
- `OpenAPI file must have a valid extension (.json, .yaml, or .yml). Found: ${filePathOrUrl}`,
722
- currentPath,
723
- );
724
- }
725
-
726
- const fullPath = path.join(DOCS_DIR, filePathOrUrl);
727
-
728
- if (!fs.existsSync(fullPath)) {
729
- throwConfigError(
730
- `Referenced OpenAPI file not found. Expected: ${filePathOrUrl}`,
731
- currentPath,
732
- );
733
- }
734
- } else {
735
- // For URLs, validate that it's a valid HTTP/HTTPS URL
736
- try {
737
- const url = new URL(filePathOrUrl);
738
- if (url.protocol !== "http:" && url.protocol !== "https:") {
739
- throwConfigError(
740
- `OpenAPI URL must use http:// or https:// protocol. Found: ${filePathOrUrl}`,
741
- currentPath,
742
- );
743
- }
744
- } catch (error) {
745
- throwConfigError(
746
- `Invalid OpenAPI URL format: ${filePathOrUrl}`,
747
- currentPath,
748
- );
749
- }
750
- }
751
-
752
- // Validate the OpenAPI spec using Spectral (works for both files and URLs)
753
- try {
754
- const document = await loadOpenApiSpec(filePathOrUrl);
755
- const tolerantRuleset = {
756
- ...oas,
757
- rules: {
758
- ...oas.rules,
759
- "oas3-schema": {
760
- ...oas.rules["oas3-schema"],
761
- severity: 1,
762
- },
763
- "oas2-schema": {
764
- ...oas.rules["oas2-schema"],
765
- severity: 1,
766
- },
767
- },
768
- };
769
-
770
- const spectral = new Spectral();
771
-
772
- spectral.setRuleset(tolerantRuleset);
773
-
774
- let results = await spectral.run(document);
775
- const blockingResults = results.filter(
776
- (result) => result.code === "unrecognized-format",
777
- );
778
-
779
- if (blockingResults.length > 0) {
780
- const errorMessages = blockingResults.slice(0, 5).map((result) => {
781
- const pathStr = result.path.length > 0 ? result.path.join(".") : "root";
782
- return `${result.message} (at ${pathStr})`;
783
- });
784
-
785
- const errorText = errorMessages.join("; ");
786
- const moreErrors =
787
- blockingResults.length > 5
788
- ? ` (and ${blockingResults.length - 5} more errors)`
789
- : "";
790
-
791
- throwConfigError(
792
- `Invalid OpenAPI specification: ${errorText}${moreErrors}`,
793
- currentPath,
794
- );
795
- }
796
-
797
- const nonBlockingResults = results.filter(
798
- (result) => result.severity !== 0,
799
- );
800
-
801
- if (nonBlockingResults.length > 0) {
802
- const warningMessages = nonBlockingResults.slice(0, 5).map((result) => {
803
- const pathStr = result.path.length > 0 ? result.path.join(".") : "root";
804
- return `${result.message} (at ${pathStr})`;
805
- });
806
-
807
- const warningText = warningMessages.join("; ");
808
- const moreWarnings =
809
- nonBlockingResults.length > warningMessages.length
810
- ? ` (and ${
811
- nonBlockingResults.length - warningMessages.length
812
- } more warnings)`
813
- : "";
814
- const sourcePath =
815
- currentPath.length > 0 ? ` (at: ${currentPath.join(".")})` : "";
816
-
817
- console.warn(
818
- `[OPENAPI_VALIDATION_WARNING] ${warningText}${moreWarnings}${sourcePath}`,
819
- );
820
- }
821
- } catch (error) {
822
- // Handle parsing errors separately from validation errors
823
- if (error instanceof SyntaxError) {
824
- throwConfigError(
825
- `Failed to parse OpenAPI file: ${error.message}`,
826
- currentPath,
827
- );
828
- } else if (error instanceof Error) {
829
- const baseMessage = error.message || String(error);
830
- const prefixedMessage = baseMessage.startsWith(
831
- "Invalid OpenAPI specification:",
832
- )
833
- ? baseMessage
834
- : `Invalid OpenAPI specification: ${baseMessage}`;
835
- throwConfigError(prefixedMessage, currentPath);
836
- } else {
837
- throwConfigError(
838
- `Invalid OpenAPI specification: ${String(error)}`,
839
- currentPath,
840
- );
841
- }
842
- }
843
- }
844
-
845
- function extractAvailableEndpoints(openApiDoc: any): Set<string> {
846
- const endpoints = new Set<string>();
847
- const paths = openApiDoc.paths || {};
848
- const httpMethods = [
849
- "get",
850
- "post",
851
- "put",
852
- "delete",
853
- "patch",
854
- "head",
855
- "options",
856
- "trace",
857
- ];
858
-
859
- for (const [pathStr, pathItem] of Object.entries(paths)) {
860
- if (!pathItem || typeof pathItem !== "object") continue;
861
-
862
- for (const method of httpMethods) {
863
- const operation = (pathItem as any)[method];
864
- if (operation) {
865
- // Store as "METHOD /path" (uppercase method, lowercase path)
866
- const normalizedMethod = method.toUpperCase();
867
- const normalizedPath = pathStr.toLowerCase();
868
- endpoints.add(`${normalizedMethod} ${normalizedPath}`);
869
- }
870
- }
871
- }
872
-
873
- return endpoints;
874
- }
875
-
876
- // Helper function to parse endpoint string (e.g., "get /burgers" or "POST /burgers")
877
- function parseEndpointString(
878
- endpointStr: string,
879
- ): { method: string; path: string } | null {
880
- const trimmed = endpointStr.trim();
881
- const parts = trimmed.split(/\s+/);
882
-
883
- if (parts.length !== 2) {
884
- return null;
885
- }
886
-
887
- const method = parts[0].toUpperCase();
888
- let path = parts[1];
889
-
890
- // Ensure path starts with /
891
- if (!path.startsWith("/")) {
892
- path = "/" + path;
893
- }
894
-
895
- // Normalize path to lowercase for comparison
896
- const normalizedPath = path.toLowerCase();
897
-
898
- return { method, path: normalizedPath };
899
- }
900
-
901
- async function validateNavOpenApiPage(
902
- navOpenApiPage: any,
903
- currentPath: Path,
904
- ): Promise<void> {
905
- checkType(navOpenApiPage, "object", currentPath, "Open API page");
906
-
907
- if (typeof navOpenApiPage.source !== "string") {
908
- throwConfigError(
909
- "Open API page must include a 'source' property that is a string.",
910
- [...currentPath, "source"],
911
- );
912
- }
913
-
914
- if (typeof navOpenApiPage.endpoint !== "string") {
915
- throwConfigError(
916
- "Open API page must include an 'endpoint' property that is a string in the format \"METHOD /path\".",
917
- [...currentPath, "endpoint"],
918
- );
919
- }
920
-
921
- const parsedEndpoint = parseEndpointString(navOpenApiPage.endpoint);
922
- if (!parsedEndpoint) {
923
- throwConfigError(
924
- `Open API page endpoint must be in the format "METHOD /path". Found: ${navOpenApiPage.endpoint}`,
925
- [...currentPath, "endpoint"],
926
- );
927
- }
928
-
929
- await validateOpenApiFile(navOpenApiPage.source, [...currentPath, "source"]);
930
-
931
- const openApiDoc = await loadOpenApiSpec(navOpenApiPage.source);
932
- const availableEndpoints = extractAvailableEndpoints(openApiDoc);
933
- const endpointKey = `${parsedEndpoint!.method} ${parsedEndpoint!.path}`;
934
-
935
- if (!availableEndpoints.has(endpointKey)) {
936
- throwConfigError(
937
- `Open API page endpoint does not match any endpoint in the OpenAPI spec. Found: ${navOpenApiPage.endpoint}. Expected format: "METHOD /path".`,
938
- [...currentPath, "endpoint"],
939
- );
940
- }
941
- }
942
-
943
- async function validateNavigationNode(
944
- item: any,
945
- currentPath: Path,
946
- groupDepth: number = 0,
947
- ): Promise<void> {
948
- // A) Base Case: Simple string path
949
- if (typeof item === "string") {
950
- const normalizedPath = normalizeDocsPagePath(item, currentPath);
951
- validateFileExistence(normalizedPath, currentPath);
952
- return;
953
- }
954
-
955
- // B) Must be an object
956
- checkType(item, "object", currentPath, "Navigation item");
957
-
958
- // Determine item type by key presence (Strict XOR enforcement)
959
- const isGroup = "group" in item;
960
- const isPage = "page" in item;
961
- const isOpenApiPage = "openapi" in item;
962
-
963
- const typeCount = [isGroup, isPage, isOpenApiPage].filter(Boolean).length;
964
- if (typeCount !== 1) {
965
- throwConfigError(
966
- "Object must contain exactly one key: 'page', 'group', or 'openapi'.",
967
- currentPath,
968
- );
969
- }
970
-
971
- // --- Validate Group (Recursive) ---
972
- if (isGroup) {
973
- const path = [...currentPath];
974
-
975
- // Enforce max group nesting depth of 2
976
- if (groupDepth >= 2) {
977
- throwConfigError("Groups can only be nested up to 2 levels deep.", path);
978
- }
979
-
980
- checkType(item.group, "string", [...path, "group"], "Group name");
981
-
982
- // C.2: THE EXPANDED CHECK (Kept clean)
983
- checkType(item.expanded, "boolean", [...path, "expanded"], "Expanded");
984
-
985
- validateIcon(item.icon, [...path, "icon"]);
986
- item.tag = normalizeNavTagConfig(item.tag, [...path, "tag"], "Group tag");
987
-
988
- // Check if pages array exists and validate children
989
- if (!item.pages)
990
- throwConfigError("Group must have a 'pages' array.", [...path, "pages"]);
991
- checkType(item.pages, "array", [...path, "pages"], "Group pages");
992
-
993
- for (const [i, child] of item.pages.entries()) {
994
- if (typeof child === "string") {
995
- const childPath = [...path, "pages", i];
996
- const normalizedPagePath = normalizeDocsPagePath(child, childPath);
997
- item.pages[i] = normalizedPagePath;
998
- validateFileExistence(normalizedPagePath, childPath);
999
- continue;
1000
- }
1001
-
1002
- await validateNavigationNode(
1003
- child,
1004
- [...path, "pages", i],
1005
- groupDepth + 1,
1006
- );
1007
- }
1008
- return;
1009
- }
1010
-
1011
- // --- Validate Page ---
1012
- if (isPage) {
1013
- const path = [...currentPath];
1014
- const normalizedPagePath = normalizeDocsPagePath(item.page, [
1015
- ...path,
1016
- "page",
1017
- ]);
1018
- item.page = normalizedPagePath;
1019
- validateFileExistence(normalizedPagePath, [...path, "page"]);
1020
-
1021
- validateIcon(item.icon, [...path, "icon"]);
1022
-
1023
- // Validate optional title
1024
- checkType(item.title, "string", [...path, "title"], "Page title");
1025
- item.tag = normalizeNavTagConfig(item.tag, [...path, "tag"], "Page tag");
1026
-
1027
- // Check D.2/D.3: Page cannot have group properties
1028
- if ("expanded" in item)
1029
- throwConfigError("Page items cannot have 'expanded'.", [
1030
- ...path,
1031
- "expanded",
1032
- ]);
1033
- if ("pages" in item)
1034
- throwConfigError("Page items cannot have children.", [...path, "pages"]);
1035
- return;
1036
- }
1037
-
1038
- if (isOpenApiPage) {
1039
- const path = [...currentPath];
1040
-
1041
- if ("icon" in item) {
1042
- throwConfigError(
1043
- "Open API page items cannot have an 'icon'. Method badges are displayed automatically.",
1044
- [...path, "icon"],
1045
- );
1046
- }
1047
-
1048
- await validateNavOpenApiPage(item.openapi, [...path, "openapi"]);
1049
- checkType(item.title, "string", [...path, "title"], "Open API page title");
1050
- item.tag = normalizeNavTagConfig(
1051
- item.tag,
1052
- [...path, "tag"],
1053
- "Open API page tag",
1054
- );
1055
-
1056
- if ("expanded" in item)
1057
- throwConfigError("Open API page items cannot have 'expanded'.", [
1058
- ...path,
1059
- "expanded",
1060
- ]);
1061
- if ("pages" in item)
1062
- throwConfigError("Open API page items cannot have children.", [
1063
- ...path,
1064
- "pages",
1065
- ]);
1066
- if ("group" in item)
1067
- throwConfigError("Open API page items cannot have 'group'.", [
1068
- ...path,
1069
- "group",
1070
- ]);
1071
- if ("page" in item)
1072
- throwConfigError("Open API page items cannot have 'page'.", [
1073
- ...path,
1074
- "page",
1075
- ]);
1076
- return;
1077
- }
1078
- }
1079
-
1080
- function getFirstPagePathFromPageItems(
1081
- items: (string | NavPage | NavGroup | NavOpenApiPage)[],
1082
- ): string | undefined {
1083
- for (const item of items) {
1084
- if (typeof item === "string") {
1085
- return item;
1086
- }
1087
-
1088
- if ("page" in item) {
1089
- return item.page;
1090
- }
1091
-
1092
- if ("group" in item) {
1093
- const nestedPath = getFirstPagePathFromPageItems(item.pages);
1094
- if (nestedPath) {
1095
- return nestedPath;
1096
- }
1097
- }
1098
- }
1099
-
1100
- return undefined;
1101
- }
1102
-
1103
- function getFirstPagePathFromNavigation(
1104
- navigation: DocsConfig["navigation"],
1105
- ): string | undefined {
1106
- if (navigation.pages) {
1107
- return getFirstPagePathFromPageItems(navigation.pages);
1108
- }
1109
-
1110
- if (navigation.menu) {
1111
- for (const menuItem of navigation.menu.items) {
1112
- const submenuPages = menuItem.submenu.pages;
1113
- if (!submenuPages) {
1114
- continue;
1115
- }
1116
-
1117
- const firstPath = getFirstPagePathFromPageItems(submenuPages);
1118
- if (firstPath) {
1119
- return firstPath;
1120
- }
1121
- }
1122
- }
1123
-
1124
- return undefined;
1125
- }
1126
-
1127
- async function validateNavOpenApi(
1128
- navOpenApi: any,
1129
- currentPath: Path,
1130
- ): Promise<void> {
1131
- checkType(navOpenApi, "object", currentPath, "Open API object");
1132
-
1133
- // Required: source (must be a string)
1134
- if (typeof navOpenApi.source !== "string") {
1135
- throwConfigError(
1136
- "Open API object must have an 'source' property that is a string.",
1137
- [...currentPath, "source"],
1138
- );
1139
- }
1140
-
1141
- // Validate the OpenAPI file exists and is valid
1142
- await validateOpenApiFile(navOpenApi.source, [...currentPath, "source"]);
1143
-
1144
- // Check mutual exclusivity of include and exclude
1145
- const hasInclude = "include" in navOpenApi;
1146
- const hasExclude = "exclude" in navOpenApi;
1147
-
1148
- if (hasInclude && hasExclude) {
1149
- throwConfigError(
1150
- "Open API object cannot have both 'include' and 'exclude' properties. They are mutually exclusive.",
1151
- currentPath,
1152
- );
1153
- }
1154
-
1155
- // If neither include nor exclude is present, that's valid (all endpoints will be included)
1156
- if (!hasInclude && !hasExclude) {
1157
- return;
1158
- }
1159
-
1160
- // Load the OpenAPI spec to validate against
1161
- const openApiDoc = await loadOpenApiSpec(navOpenApi.source);
1162
- const availableEndpoints = extractAvailableEndpoints(openApiDoc);
1163
-
1164
- // Validate include array
1165
- if (hasInclude) {
1166
- checkType(
1167
- navOpenApi.include,
1168
- "array",
1169
- [...currentPath, "include"],
1170
- "Include array",
1171
- );
1172
-
1173
- if (navOpenApi.include.length === 0) {
1174
- throwConfigError("Include array cannot be empty.", [
1175
- ...currentPath,
1176
- "include",
1177
- ]);
1178
- }
1179
-
1180
- // Validate each entry
1181
- for (const [i, entry] of navOpenApi.include.entries()) {
1182
- if (typeof entry !== "string") {
1183
- throwConfigError(
1184
- `Include entry at index ${i} must be a string in the format "METHOD /path".`,
1185
- [...currentPath, "include", i],
1186
- );
1187
- }
1188
-
1189
- const parsed = parseEndpointString(entry);
1190
- if (!parsed) {
1191
- throwConfigError(
1192
- `Include entry at index ${i} must be in the format "METHOD /path". Found: ${entry}`,
1193
- [...currentPath, "include", i],
1194
- );
1195
- }
1196
-
1197
- // Check if endpoint exists in the OpenAPI spec
1198
- const endpointKey = `${parsed?.method} ${parsed?.path}`;
1199
- if (!availableEndpoints.has(endpointKey)) {
1200
- throwConfigError(
1201
- `Include entry at index ${i} does not match any endpoint in the OpenAPI spec. Found: ${entry}. Expected format: "METHOD /path".`,
1202
- [...currentPath, "include", i],
1203
- );
1204
- }
1205
- }
1206
- }
1207
-
1208
- // Validate exclude array
1209
- if (hasExclude) {
1210
- checkType(
1211
- navOpenApi.exclude,
1212
- "array",
1213
- [...currentPath, "exclude"],
1214
- "Exclude array",
1215
- );
1216
-
1217
- if (navOpenApi.exclude.length === 0) {
1218
- throwConfigError("Exclude array cannot be empty.", [
1219
- ...currentPath,
1220
- "exclude",
1221
- ]);
1222
- }
1223
-
1224
- // Validate each entry
1225
- for (const [i, entry] of navOpenApi.exclude.entries()) {
1226
- if (typeof entry !== "string") {
1227
- throwConfigError(
1228
- `Exclude entry at index ${i} must be a string in the format "METHOD /path".`,
1229
- [...currentPath, "exclude", i],
1230
- );
1231
- }
1232
-
1233
- const parsed = parseEndpointString(entry);
1234
- if (!parsed) {
1235
- throwConfigError(
1236
- `Exclude entry at index ${i} must be in the format "METHOD /path" (e.g., "get /burgers"). Found: ${entry}`,
1237
- [...currentPath, "exclude", i],
1238
- );
1239
- }
1240
-
1241
- // Check if endpoint exists in the OpenAPI spec
1242
- const endpointKey = `${parsed?.method} ${parsed?.path}`;
1243
- if (!availableEndpoints.has(endpointKey)) {
1244
- throwConfigError(
1245
- `Exclude entry at index ${i} does not match any endpoint in the OpenAPI spec. Found: ${entry}. Expected format: "METHOD /path" (case-insensitive).`,
1246
- [...currentPath, "exclude", i],
1247
- );
1248
- }
1249
- }
1250
- }
1251
- }
1252
-
1253
- async function validateNavMenuItem(item: NavMenuItem, currentPath: Path) {
1254
- checkType(item, "object", currentPath, "Menu item");
1255
-
1256
- validateIcon(item.icon, [...currentPath, "icon"]);
1257
-
1258
- // Required: label
1259
- if (!item.label) {
1260
- throwConfigError("Menu item must have a 'label' property.", [
1261
- ...currentPath,
1262
- "label",
1263
- ]);
1264
- }
1265
- checkType(item.label, "string", [...currentPath, "label"], "Label");
1266
-
1267
- // Required: submenu
1268
- if (!item.submenu) {
1269
- throwConfigError("Menu item must have a 'submenu' property.", [
1270
- ...currentPath,
1271
- "submenu",
1272
- ]);
1273
- }
1274
- checkType(item.submenu, "object", [...currentPath, "submenu"], "Submenu");
1275
-
1276
- const submenu = item.submenu;
1277
- const submenuKeys = Object.keys(submenu);
1278
- const validSubmenuKeys = ["pages", "openapi"];
1279
- const presentSubmenuKeys = submenuKeys.filter((key) =>
1280
- validSubmenuKeys.includes(key),
1281
- );
1282
- const invalidSubmenuKeys = submenuKeys.filter(
1283
- (key) => !validSubmenuKeys.includes(key),
1284
- );
1285
-
1286
- // Submenu must have exactly one key total
1287
- if (submenuKeys.length !== 1) {
1288
- if (submenuKeys.length === 0) {
1289
- throwConfigError(
1290
- `Submenu must contain exactly one key (${validSubmenuKeys.join(
1291
- ", ",
1292
- )}). Found no keys.`,
1293
- [...currentPath, "submenu"],
1294
- );
1295
- } else {
1296
- throwConfigError(
1297
- `Submenu must contain exactly one key (${validSubmenuKeys.join(
1298
- ", ",
1299
- )}). Found ${submenuKeys.length} key(s): ${submenuKeys.join(", ")}.`,
1300
- [...currentPath, "submenu"],
1301
- );
1302
- }
1303
- }
1304
-
1305
- // Check if the single key is valid
1306
- if (presentSubmenuKeys.length !== 1) {
1307
- const invalidKey = invalidSubmenuKeys[0];
1308
- throwConfigError(
1309
- `Submenu must contain exactly one key (${validSubmenuKeys.join(
1310
- ", ",
1311
- )}). Found invalid key: ${invalidKey}.`,
1312
- [...currentPath, "submenu"],
1313
- );
1314
- }
1315
-
1316
- const submenuKey = presentSubmenuKeys[0];
1317
- const submenuValue =
1318
- submenu[submenuKey as keyof Omit<NavigationItem, "menu">];
1319
-
1320
- // Validate pages array
1321
- if (submenuKey === "pages") {
1322
- checkType(
1323
- submenuValue,
1324
- "array",
1325
- [...currentPath, "submenu", "pages"],
1326
- "Submenu pages",
1327
- );
1328
- const pages = (submenuValue as NavigationItem["pages"]) ?? [];
1329
- for (const [i, item] of pages.entries()) {
1330
- const itemPath = [...currentPath, "submenu", "pages", i];
1331
- if (typeof item === "string") {
1332
- const normalizedPagePath = normalizeDocsPagePath(item, itemPath);
1333
- (submenuValue as (string | NavPage | NavGroup | NavOpenApiPage)[])[i] =
1334
- normalizedPagePath;
1335
- validateFileExistence(normalizedPagePath, itemPath);
1336
- } else {
1337
- await validateNavigationNode(item, itemPath);
1338
- }
1339
- }
1340
- }
1341
-
1342
- // Validate openapi - can be string or NavOpenApi object
1343
- if (submenuKey === "openapi") {
1344
- if (typeof submenuValue === "string") {
1345
- // Simple string case - validate file exists and is valid
1346
- await validateOpenApiFile(submenuValue, [
1347
- ...currentPath,
1348
- "submenu",
1349
- "openapi",
1350
- ]);
1351
- } else if (typeof submenuValue === "object") {
1352
- // NavOpenApi object case - validate structure and include/exclude
1353
- await validateNavOpenApi(submenuValue, [
1354
- ...currentPath,
1355
- "submenu",
1356
- "openapi",
1357
- ]);
1358
- } else {
1359
- throwConfigError(
1360
- "OpenAPI must be either a string (file path or hosted file) or an object.",
1361
- [...currentPath, "submenu", "openapi"],
1362
- );
1363
- }
1364
- }
1365
- }
1366
-
1367
- async function validateNavMenu(menu: any, currentPath: Path) {
1368
- checkType(menu, "object", currentPath, "Menu");
1369
-
1370
- // Optional: type
1371
- if (menu.type !== undefined) {
1372
- if (menu.type !== "dropdown" && menu.type !== "segmented") {
1373
- throwConfigError(
1374
- "Menu type must be 'dropdown' or 'segmented' if provided. Defaults to 'dropdown'",
1375
- [...currentPath, "type"],
1376
- );
1377
- }
1378
- }
1379
-
1380
- // Optional: label
1381
- checkType(menu.label, "string", [...currentPath, "label"], "Menu label");
1382
-
1383
- // Required: items
1384
- if (!menu.items) {
1385
- throwConfigError("Menu must have an 'items' array.", [
1386
- ...currentPath,
1387
- "items",
1388
- ]);
1389
- }
1390
- checkType(menu.items, "array", [...currentPath, "items"], "Menu items");
1391
-
1392
- // Validate each menu item
1393
- for (const [i, item] of menu.items.entries()) {
1394
- await validateNavMenuItem(item, [...currentPath, "items", i]);
1395
- }
1396
- }
1397
-
1398
- function validateNavbarItem(
1399
- item: any,
1400
- currentPath: Path,
1401
- ): HiddenPageRoute | null {
1402
- // Check if object exists, otherwise we skip (it's optional)
1403
- if (item === undefined) return null;
1404
-
1405
- checkType(item, "object", currentPath, "Navbar item");
1406
-
1407
- // Required properties
1408
- if (typeof item.text !== "string") {
1409
- throwConfigError("Navbar item must have a 'text' property.", [
1410
- ...currentPath,
1411
- "text",
1412
- ]);
1413
- }
1414
- if (typeof item.href !== "string") {
1415
- throwConfigError("Navbar item must have an 'href' property.", [
1416
- ...currentPath,
1417
- "href",
1418
- ]);
1419
- }
1420
-
1421
- const hiddenPageRoute = normalizeInternalPageHref(
1422
- item.href,
1423
- [...currentPath, "href"],
1424
- "Navbar item href",
1425
- );
1426
- if (hiddenPageRoute) {
1427
- item.href = hiddenPageRoute.linkHref;
1428
- }
1429
-
1430
- // Optional property
1431
- validateIcon(item.icon, [...currentPath, "icon"]);
1432
- if (item.color !== undefined) {
1433
- if (currentPath[0] !== "navbar" || currentPath[1] !== "primary") {
1434
- throwConfigError(
1435
- "Navbar item color is only supported on navbar.primary.",
1436
- [...currentPath, "color"],
1437
- );
1438
- }
1439
-
1440
- item.color = normalizeThemeColorConfig(
1441
- item.color,
1442
- [...currentPath, "color"],
1443
- "Navbar primary color",
1444
- );
1445
- }
1446
-
1447
- return hiddenPageRoute;
1448
- }
1449
-
1450
- // --- Top-Level Validation Functions (Your Clean API) ---
1451
-
1452
- function validateTitle(title: DocsConfig["title"]) {
1453
- checkType(title, "string", ["title"], "Title");
1454
- if (!title) throwConfigError("Title is missing.", ["title"]);
1455
- }
1456
-
1457
- function validateLogoPaddingValue(
1458
- value: unknown,
1459
- currentPath: Path,
1460
- label: string,
1461
- ): void {
1462
- if (value === undefined) return;
1463
-
1464
- if (typeof value !== "number" || !Number.isFinite(value)) {
1465
- throwConfigError(`${label} must be a finite number.`, currentPath);
1466
- }
1467
-
1468
- const numericValue = value as number;
1469
- if (numericValue < 0) {
1470
- throwConfigError(`${label} cannot be negative.`, currentPath);
1471
- }
1472
- }
1473
-
1474
- function validateLogoImagePath(
1475
- imagePath: string,
1476
- currentPath: Path,
1477
- label: string,
1478
- ): void {
1479
- const validExtensions = [".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif"];
1480
- const hasValidExtension = validExtensions.some((ext) =>
1481
- imagePath.toLowerCase().endsWith(ext),
1482
- );
1483
- if (!hasValidExtension) {
1484
- throwConfigError(
1485
- `${label} must be a valid image file (.svg, .png, .jpg, .jpeg, .webp, .gif)`,
1486
- currentPath,
1487
- );
1488
- }
1489
-
1490
- const normalizedPath = imagePath.startsWith("/")
1491
- ? imagePath.slice(1)
1492
- : imagePath;
1493
- const fullPath = path.join(DOCS_DIR, normalizedPath);
1494
-
1495
- if (!fs.existsSync(fullPath)) {
1496
- throwConfigError(
1497
- `${label} file not found. Expected: ${normalizedPath}`,
1498
- currentPath,
1499
- );
1500
- }
1501
- }
1502
-
1503
- function validateAssistantIconSource(
1504
- iconSource: unknown,
1505
- currentPath: Path,
1506
- ): string {
1507
- checkType(iconSource, "string", currentPath, "Assistant icon source");
1508
- if (typeof iconSource !== "string") {
1509
- throwConfigError("Assistant icon source must be a string.", currentPath);
1510
- }
1511
-
1512
- const trimmedSource = iconSource.trim();
1513
- if (trimmedSource.length === 0) {
1514
- throwConfigError("Assistant icon source cannot be empty.", currentPath);
1515
- }
1516
-
1517
- if (isUrl(trimmedSource)) {
1518
- throwConfigError(
1519
- "Assistant icon source must be a local image path relative to docs.json.",
1520
- currentPath,
1521
- );
1522
- }
1523
-
1524
- if (trimmedSource.includes(":")) {
1525
- throwConfigError(
1526
- `Invalid assistant icon source: "${trimmedSource}". Assistant icons must be local image files, not Iconify icon names.`,
1527
- currentPath,
1528
- );
1529
- }
1530
-
1531
- if (
1532
- trimmedSource.startsWith("//") ||
1533
- trimmedSource.startsWith("#") ||
1534
- trimmedSource.startsWith("?") ||
1535
- trimmedSource.startsWith("./") ||
1536
- trimmedSource.startsWith("../")
1537
- ) {
1538
- throwConfigError(
1539
- "Assistant icon source must be a local image path relative to docs.json.",
1540
- currentPath,
1541
- );
1542
- }
1543
-
1544
- const parsed = new URL(trimmedSource, "https://docs.invalid/");
1545
- const validExtensions = [
1546
- ".svg",
1547
- ".png",
1548
- ".jpg",
1549
- ".jpeg",
1550
- ".webp",
1551
- ".gif",
1552
- ".ico",
1553
- ".avif",
1554
- ];
1555
- const hasValidExtension = validExtensions.some((ext) =>
1556
- parsed.pathname.toLowerCase().endsWith(ext),
1557
- );
1558
- if (!hasValidExtension) {
1559
- throwConfigError(
1560
- "Assistant icon source must be a valid image file (.svg, .png, .jpg, .jpeg, .webp, .gif, .ico, .avif).",
1561
- currentPath,
1562
- );
1563
- }
1564
-
1565
- const normalizedPath = parsed.pathname.replace(/^\/+/, "");
1566
- const fullPath = path.join(DOCS_DIR, normalizedPath);
1567
-
1568
- if (!fs.existsSync(fullPath)) {
1569
- throwConfigError(
1570
- `Assistant icon source file not found. Expected: ${normalizedPath}`,
1571
- currentPath,
1572
- );
1573
- }
1574
-
1575
- return trimmedSource;
1576
- }
1577
-
1578
- function validateLogoVariant(
1579
- variant: LogoVariant | undefined,
1580
- currentPath: Path,
1581
- mode: "light" | "dark",
1582
- ): void {
1583
- if (variant === undefined) return;
1584
-
1585
- if (typeof variant === "string") {
1586
- validateLogoImagePath(variant, currentPath, `Logo ${mode}`);
1587
- return;
1588
- }
1589
-
1590
- if (
1591
- typeof variant !== "object" ||
1592
- variant === null ||
1593
- Array.isArray(variant)
1594
- ) {
1595
- throwConfigError(
1596
- `Logo ${mode} must be a string path or an object with 'image' and optional 'padding'.`,
1597
- currentPath,
1598
- );
1599
- }
1600
-
1601
- const supportedKeys = new Set(["image", "padding"]);
1602
- for (const key of Object.keys(variant)) {
1603
- if (!supportedKeys.has(key)) {
1604
- throwConfigError(
1605
- `Logo ${mode} object only supports 'image' and 'padding'.`,
1606
- [...currentPath, key],
1607
- );
1608
- }
1609
- }
1610
-
1611
- if (typeof variant.image !== "string") {
1612
- throwConfigError(`Logo ${mode} object must include an 'image' string.`, [
1613
- ...currentPath,
1614
- "image",
1615
- ]);
1616
- }
1617
-
1618
- validateLogoImagePath(
1619
- variant.image,
1620
- [...currentPath, "image"],
1621
- `Logo ${mode} image`,
1622
- );
1623
-
1624
- if (variant.padding === undefined) return;
1625
-
1626
- if (
1627
- typeof variant.padding !== "object" ||
1628
- variant.padding === null ||
1629
- Array.isArray(variant.padding)
1630
- ) {
1631
- throwConfigError(
1632
- `Logo ${mode} padding must be an object with optional 'top' and 'bottom'.`,
1633
- [...currentPath, "padding"],
1634
- );
1635
- }
1636
-
1637
- const paddingKeys = Object.keys(variant.padding);
1638
- for (const key of paddingKeys) {
1639
- if (key !== "top" && key !== "bottom") {
1640
- throwConfigError(
1641
- `Logo ${mode} padding only supports 'top' and 'bottom'.`,
1642
- [...currentPath, "padding", key],
1643
- );
1644
- }
1645
- }
1646
-
1647
- validateLogoPaddingValue(
1648
- variant.padding.top,
1649
- [...currentPath, "padding", "top"],
1650
- `Logo ${mode} padding top`,
1651
- );
1652
- validateLogoPaddingValue(
1653
- variant.padding.bottom,
1654
- [...currentPath, "padding", "bottom"],
1655
- `Logo ${mode} padding bottom`,
1656
- );
1657
- }
1658
-
1659
- function validateLogo(logo: DocsConfig["logo"]) {
1660
- // Logo is optional, so if it's undefined, we're done
1661
- if (logo === undefined) return;
1662
-
1663
- // If logo is provided, it must be an object
1664
- checkType(logo, "object", ["logo"], "Logo configuration");
1665
-
1666
- validateLogoVariant(logo.light, ["logo", "light"], "light");
1667
- validateLogoVariant(logo.dark, ["logo", "dark"], "dark");
1668
-
1669
- // Validate 'href' if provided
1670
- if (logo.href !== undefined) {
1671
- checkType(logo.href, "string", ["logo", "href"], "Logo href");
1672
-
1673
- // Validate it's either a valid URL or a valid internal path
1674
- // Internal paths should start with /
1675
- // External URLs should start with http:// or https://
1676
- const trimmedHref = logo.href.trim();
1677
-
1678
- if (trimmedHref === "") {
1679
- throwConfigError("Logo href cannot be an empty string", ["logo", "href"]);
1680
- }
1681
-
1682
- // Check if it's a URL
1683
- const isUrl =
1684
- trimmedHref.startsWith("http://") || trimmedHref.startsWith("https://");
1685
-
1686
- // Check if it's an internal path (starts with /)
1687
- const isInternalPath = trimmedHref.startsWith("/");
1688
-
1689
- if (!isUrl && !isInternalPath) {
1690
- throwConfigError(
1691
- "Logo href must be either a valid URL (http:// or https://) or an internal path (starting with /)",
1692
- ["logo", "href"],
1693
- );
1694
- }
1695
- }
1696
-
1697
- if (logo.pill !== undefined) {
1698
- if (typeof logo.pill === "string") {
1699
- if (logo.pill.trim() === "") {
1700
- throwConfigError(
1701
- "Logo pill text cannot be an empty string. Use false to hide the pill.",
1702
- ["logo", "pill"],
1703
- );
1704
- }
1705
- } else if (logo.pill !== false) {
1706
- throwConfigError("Logo pill must be a string or false.", [
1707
- "logo",
1708
- "pill",
1709
- ]);
1710
- }
1711
- }
1712
- }
1713
-
1714
- function validateTheme(theme: DocsConfig["theme"]): void {
1715
- if (theme === undefined) return;
1716
-
1717
- checkType(theme, "object", ["theme"], "Theme configuration");
1718
- if (typeof theme !== "object" || theme === null || Array.isArray(theme)) {
1719
- throwConfigError("Theme configuration must be an object.", ["theme"]);
1720
- }
1721
-
1722
- const normalizeBaseColor = (
1723
- value: unknown,
1724
- currentPath: Path,
1725
- label: string,
1726
- ): BaseColorOption => {
1727
- checkType(value, "string", currentPath, label);
1728
- if (typeof value !== "string") {
1729
- throwConfigError(`${label} must be a string.`, currentPath);
1730
- }
1731
-
1732
- const normalizedBaseColor = (value as string).trim().toLowerCase();
1733
- if (normalizedBaseColor.length === 0) {
1734
- throwConfigError(`${label} cannot be empty.`, currentPath);
1735
- }
1736
-
1737
- if (!BASE_COLOR_OPTIONS.includes(normalizedBaseColor as BaseColorOption)) {
1738
- throwConfigError(
1739
- `${label} must be one of: ${BASE_COLOR_OPTIONS.join(", ")}.`,
1740
- currentPath,
1741
- );
1742
- }
1743
-
1744
- return normalizedBaseColor as BaseColorOption;
1745
- };
1746
-
1747
- if (theme.baseColor !== undefined) {
1748
- if (typeof theme.baseColor === "string") {
1749
- theme.baseColor = normalizeBaseColor(
1750
- theme.baseColor,
1751
- ["theme", "baseColor"],
1752
- "Theme base color",
1753
- );
1754
- } else {
1755
- checkType(
1756
- theme.baseColor,
1757
- "object",
1758
- ["theme", "baseColor"],
1759
- "Theme base color",
1760
- );
1761
- if (
1762
- typeof theme.baseColor !== "object" ||
1763
- theme.baseColor === null ||
1764
- Array.isArray(theme.baseColor)
1765
- ) {
1766
- throwConfigError(
1767
- "Theme base color must be a string or an object with light/dark values.",
1768
- ["theme", "baseColor"],
1769
- );
1770
- }
1771
-
1772
- const baseColorByMode = theme.baseColor as Record<string, unknown>;
1773
- const allowedKeys = new Set(["light", "dark"]);
1774
- for (const key of Object.keys(baseColorByMode)) {
1775
- if (!allowedKeys.has(key)) {
1776
- throwConfigError(
1777
- "Theme base color object only supports 'light' and 'dark'.",
1778
- ["theme", "baseColor", key],
1779
- );
1780
- }
1781
- }
1782
-
1783
- const light =
1784
- baseColorByMode.light !== undefined
1785
- ? normalizeBaseColor(
1786
- baseColorByMode.light,
1787
- ["theme", "baseColor", "light"],
1788
- "Theme base color light",
1789
- )
1790
- : undefined;
1791
- const dark =
1792
- baseColorByMode.dark !== undefined
1793
- ? normalizeBaseColor(
1794
- baseColorByMode.dark,
1795
- ["theme", "baseColor", "dark"],
1796
- "Theme base color dark",
1797
- )
1798
- : undefined;
1799
-
1800
- if (!light && !dark) {
1801
- throwConfigError(
1802
- "Theme base color object must include 'light', 'dark', or both.",
1803
- ["theme", "baseColor"],
1804
- );
1805
- }
1806
-
1807
- const resolvedLight: BaseColorOption = light ?? "neutral";
1808
- const resolvedDark: BaseColorOption = dark ?? "neutral";
1809
-
1810
- theme.baseColor = {
1811
- light: resolvedLight,
1812
- dark: resolvedDark,
1813
- };
1814
- }
1815
- }
1816
-
1817
- const normalizeShikiThemeName = (
1818
- value: unknown,
1819
- currentPath: Path,
1820
- label: string,
1821
- ): string => {
1822
- checkType(value, "string", currentPath, label);
1823
- if (typeof value !== "string") {
1824
- throwConfigError(`${label} must be a string.`, currentPath);
1825
- }
1826
-
1827
- const normalizedThemeName = value.trim().toLowerCase();
1828
- if (normalizedThemeName.length === 0) {
1829
- throwConfigError(`${label} cannot be empty.`, currentPath);
1830
- }
1831
-
1832
- if (!isBundledShikiThemeName(normalizedThemeName)) {
1833
- throwConfigError(
1834
- `${label} must be a bundled Shiki theme name. Supported themes include: ${SHIKI_BUNDLED_THEME_NAMES.join(", ")}.`,
1835
- currentPath,
1836
- );
1837
- }
1838
-
1839
- return normalizedThemeName;
1840
- };
1841
-
1842
- if (theme.code !== undefined) {
1843
- checkType(theme.code, "object", ["theme", "code"], "Theme code");
1844
- if (
1845
- typeof theme.code !== "object" ||
1846
- theme.code === null ||
1847
- Array.isArray(theme.code)
1848
- ) {
1849
- throwConfigError("Theme code must be an object.", ["theme", "code"]);
1850
- }
1851
-
1852
- const codeTheme = theme.code as CodeTheme & Record<string, unknown>;
1853
- const allowedCodeKeys = new Set(["syntaxTheme"]);
1854
- for (const key of Object.keys(codeTheme)) {
1855
- if (!allowedCodeKeys.has(key)) {
1856
- throwConfigError(
1857
- "Theme code configuration only supports 'syntaxTheme'.",
1858
- ["theme", "code", key],
1859
- );
1860
- }
1861
- }
1862
-
1863
- if (codeTheme.syntaxTheme !== undefined) {
1864
- if (typeof codeTheme.syntaxTheme === "string") {
1865
- const themeName = normalizeShikiThemeName(
1866
- codeTheme.syntaxTheme,
1867
- ["theme", "code", "syntaxTheme"],
1868
- "Theme code syntax theme",
1869
- );
1870
- codeTheme.syntaxTheme = {
1871
- light: themeName,
1872
- dark: themeName,
1873
- };
1874
- } else {
1875
- checkType(
1876
- codeTheme.syntaxTheme,
1877
- "object",
1878
- ["theme", "code", "syntaxTheme"],
1879
- "Theme code syntax theme",
1880
- );
1881
- if (
1882
- typeof codeTheme.syntaxTheme !== "object" ||
1883
- codeTheme.syntaxTheme === null ||
1884
- Array.isArray(codeTheme.syntaxTheme)
1885
- ) {
1886
- throwConfigError(
1887
- "Theme code syntax theme must be a string or an object with light/dark values.",
1888
- ["theme", "code", "syntaxTheme"],
1889
- );
1890
- }
1891
-
1892
- const syntaxThemeByMode = codeTheme.syntaxTheme as Record<
1893
- string,
1894
- unknown
1895
- >;
1896
- const allowedSyntaxThemeKeys = new Set(["light", "dark"]);
1897
- for (const key of Object.keys(syntaxThemeByMode)) {
1898
- if (!allowedSyntaxThemeKeys.has(key)) {
1899
- throwConfigError(
1900
- "Theme code syntax theme object only supports 'light' and 'dark'.",
1901
- ["theme", "code", "syntaxTheme", key],
1902
- );
1903
- }
1904
- }
1905
-
1906
- const light =
1907
- syntaxThemeByMode.light !== undefined
1908
- ? normalizeShikiThemeName(
1909
- syntaxThemeByMode.light,
1910
- ["theme", "code", "syntaxTheme", "light"],
1911
- "Theme code syntax theme light",
1912
- )
1913
- : undefined;
1914
- const dark =
1915
- syntaxThemeByMode.dark !== undefined
1916
- ? normalizeShikiThemeName(
1917
- syntaxThemeByMode.dark,
1918
- ["theme", "code", "syntaxTheme", "dark"],
1919
- "Theme code syntax theme dark",
1920
- )
1921
- : undefined;
1922
-
1923
- if (!light && !dark) {
1924
- throwConfigError(
1925
- "Theme code syntax theme object must include 'light', 'dark', or both.",
1926
- ["theme", "code", "syntaxTheme"],
1927
- );
1928
- }
1929
-
1930
- codeTheme.syntaxTheme = {
1931
- light: light ?? DEFAULT_SHIKI_LIGHT_THEME,
1932
- dark: dark ?? DEFAULT_SHIKI_DARK_THEME,
1933
- };
1934
- }
1935
- }
1936
- }
1937
-
1938
- if (theme.tag !== undefined) {
1939
- checkType(theme.tag, "object", ["theme", "tag"], "Theme tag");
1940
- if (
1941
- typeof theme.tag !== "object" ||
1942
- theme.tag === null ||
1943
- Array.isArray(theme.tag)
1944
- ) {
1945
- throwConfigError("Theme tag must be an object.", ["theme", "tag"]);
1946
- }
1947
-
1948
- const tagTheme = theme.tag as TagTheme & Record<string, unknown>;
1949
- const allowedTagKeys = new Set(["color"]);
1950
- for (const key of Object.keys(tagTheme)) {
1951
- if (!allowedTagKeys.has(key)) {
1952
- throwConfigError("Theme tag configuration only supports 'color'.", [
1953
- "theme",
1954
- "tag",
1955
- key,
1956
- ]);
1957
- }
1958
- }
1959
-
1960
- if (tagTheme.color !== undefined) {
1961
- tagTheme.color = normalizeThemeColorConfig(
1962
- tagTheme.color,
1963
- ["theme", "tag", "color"],
1964
- "Theme tag color",
1965
- );
1966
- }
1967
- }
1968
-
1969
- if (theme.card !== undefined) {
1970
- checkType(theme.card, "object", ["theme", "card"], "Theme card");
1971
- if (
1972
- typeof theme.card !== "object" ||
1973
- theme.card === null ||
1974
- Array.isArray(theme.card)
1975
- ) {
1976
- throwConfigError("Theme card must be an object.", ["theme", "card"]);
1977
- }
1978
-
1979
- const cardTheme = theme.card as CardTheme & Record<string, unknown>;
1980
- const allowedCardKeys = new Set(["cover", "button"]);
1981
- for (const key of Object.keys(cardTheme)) {
1982
- if (!allowedCardKeys.has(key)) {
1983
- throwConfigError(
1984
- "Theme card configuration only supports 'cover' and 'button'.",
1985
- ["theme", "card", key],
1986
- );
1987
- }
1988
- }
1989
-
1990
- if (cardTheme.cover !== undefined) {
1991
- checkType(
1992
- cardTheme.cover,
1993
- "object",
1994
- ["theme", "card", "cover"],
1995
- "Theme card cover",
1996
- );
1997
- if (
1998
- typeof cardTheme.cover !== "object" ||
1999
- cardTheme.cover === null ||
2000
- Array.isArray(cardTheme.cover)
2001
- ) {
2002
- throwConfigError("Theme card cover must be an object.", [
2003
- "theme",
2004
- "card",
2005
- "cover",
2006
- ]);
2007
- }
2008
-
2009
- const coverTheme = cardTheme.cover as CardCoverTheme &
2010
- Record<string, unknown>;
2011
- const allowedCoverKeys = new Set(["colors", "colorSeed"]);
2012
- for (const key of Object.keys(coverTheme)) {
2013
- if (!allowedCoverKeys.has(key)) {
2014
- throwConfigError(
2015
- "Theme card cover configuration only supports 'colors' and 'colorSeed'.",
2016
- ["theme", "card", "cover", key],
2017
- );
2018
- }
2019
- }
2020
-
2021
- if (coverTheme.colors !== undefined) {
2022
- coverTheme.colors = normalizeHexColorArray(
2023
- coverTheme.colors,
2024
- ["theme", "card", "cover", "colors"],
2025
- "Theme card cover colors",
2026
- );
2027
- }
2028
-
2029
- if (coverTheme.colorSeed !== undefined) {
2030
- coverTheme.colorSeed = normalizeSeedValue(
2031
- coverTheme.colorSeed,
2032
- ["theme", "card", "cover", "colorSeed"],
2033
- "Theme card cover color seed",
2034
- );
2035
- }
2036
- }
2037
-
2038
- if (cardTheme.button !== undefined) {
2039
- checkType(
2040
- cardTheme.button,
2041
- "object",
2042
- ["theme", "card", "button"],
2043
- "Theme card button",
2044
- );
2045
- if (
2046
- typeof cardTheme.button !== "object" ||
2047
- cardTheme.button === null ||
2048
- Array.isArray(cardTheme.button)
2049
- ) {
2050
- throwConfigError("Theme card button must be an object.", [
2051
- "theme",
2052
- "card",
2053
- "button",
2054
- ]);
2055
- }
2056
-
2057
- const buttonTheme = cardTheme.button as CardButtonTheme &
2058
- Record<string, unknown>;
2059
- const allowedButtonKeys = new Set(["color"]);
2060
- for (const key of Object.keys(buttonTheme)) {
2061
- if (!allowedButtonKeys.has(key)) {
2062
- throwConfigError(
2063
- "Theme card button configuration only supports 'color'.",
2064
- ["theme", "card", "button", key],
2065
- );
2066
- }
2067
- }
2068
-
2069
- if (buttonTheme.color !== undefined) {
2070
- buttonTheme.color = normalizeThemeColorConfig(
2071
- buttonTheme.color,
2072
- ["theme", "card", "button", "color"],
2073
- "Theme card button color",
2074
- );
2075
- }
2076
- }
2077
- }
2078
-
2079
- if (theme.themeColor === undefined) {
2080
- return;
2081
- }
2082
-
2083
- if (typeof theme.themeColor === "string") {
2084
- theme.themeColor = normalizeHexColor(
2085
- theme.themeColor,
2086
- ["theme", "themeColor"],
2087
- "Theme color",
2088
- );
2089
- return;
2090
- }
2091
-
2092
- checkType(theme.themeColor, "object", ["theme", "themeColor"], "Theme color");
2093
- if (
2094
- typeof theme.themeColor !== "object" ||
2095
- theme.themeColor === null ||
2096
- Array.isArray(theme.themeColor)
2097
- ) {
2098
- throwConfigError(
2099
- "Theme color must be a string or an object with light/dark values.",
2100
- ["theme", "themeColor"],
2101
- );
2102
- }
2103
-
2104
- const themeColorByMode = theme.themeColor as Record<string, unknown>;
2105
- const allowedKeys = new Set(["light", "dark"]);
2106
- for (const key of Object.keys(themeColorByMode)) {
2107
- if (!allowedKeys.has(key)) {
2108
- throwConfigError("Theme color object only supports 'light' and 'dark'.", [
2109
- "theme",
2110
- "themeColor",
2111
- key,
2112
- ]);
2113
- }
2114
- }
2115
-
2116
- const light =
2117
- themeColorByMode.light !== undefined
2118
- ? normalizeHexColor(
2119
- themeColorByMode.light,
2120
- ["theme", "themeColor", "light"],
2121
- "Theme color light",
2122
- )
2123
- : undefined;
2124
- const dark =
2125
- themeColorByMode.dark !== undefined
2126
- ? normalizeHexColor(
2127
- themeColorByMode.dark,
2128
- ["theme", "themeColor", "dark"],
2129
- "Theme color dark",
2130
- )
2131
- : undefined;
2132
-
2133
- if (!light && !dark) {
2134
- throwConfigError(
2135
- "Theme color object must include 'light', 'dark', or both.",
2136
- ["theme", "themeColor"],
2137
- );
2138
- }
2139
-
2140
- theme.themeColor = {
2141
- ...(light !== undefined ? { light } : {}),
2142
- ...(dark !== undefined ? { dark } : {}),
2143
- };
2144
- }
2145
-
2146
- function validateAssistant(assistant: DocsConfig["assistant"]): void {
2147
- if (assistant === undefined) return;
2148
-
2149
- checkType(assistant, "object", ["assistant"], "Assistant configuration");
2150
- if (
2151
- typeof assistant !== "object" ||
2152
- assistant === null ||
2153
- Array.isArray(assistant)
2154
- ) {
2155
- throwConfigError("Assistant configuration must be an object.", [
2156
- "assistant",
2157
- ]);
2158
- }
2159
-
2160
- const allowedAssistantKeys = new Set([
2161
- "button",
2162
- "navbarButton",
2163
- "heading",
2164
- "questions",
2165
- "icon",
2166
- ]);
2167
- for (const key of Object.keys(assistant)) {
2168
- if (!allowedAssistantKeys.has(key)) {
2169
- throwConfigError(
2170
- "Assistant configuration only supports 'button', 'navbarButton', 'heading', 'questions', and 'icon'.",
2171
- ["assistant", key],
2172
- );
2173
- }
2174
- }
2175
-
2176
- if (assistant.button !== undefined) {
2177
- checkType(
2178
- assistant.button,
2179
- "object",
2180
- ["assistant", "button"],
2181
- "Assistant button configuration",
2182
- );
2183
- if (
2184
- typeof assistant.button !== "object" ||
2185
- assistant.button === null ||
2186
- Array.isArray(assistant.button)
2187
- ) {
2188
- throwConfigError("Assistant button configuration must be an object.", [
2189
- "assistant",
2190
- "button",
2191
- ]);
2192
- }
2193
-
2194
- const allowedButtonKeys = new Set(["size", "color"]);
2195
- for (const key of Object.keys(assistant.button)) {
2196
- if (!allowedButtonKeys.has(key)) {
2197
- throwConfigError(
2198
- "Assistant button configuration only supports 'size' and 'color'.",
2199
- ["assistant", "button", key],
2200
- );
2201
- }
2202
- }
2203
-
2204
- if (assistant.button.size !== undefined) {
2205
- checkType(
2206
- assistant.button.size,
2207
- "string",
2208
- ["assistant", "button", "size"],
2209
- "Assistant button size",
2210
- );
2211
- if (typeof assistant.button.size !== "string") {
2212
- throwConfigError("Assistant button size must be a string.", [
2213
- "assistant",
2214
- "button",
2215
- "size",
2216
- ]);
2217
- }
2218
-
2219
- const trimmedSize = assistant.button.size.trim();
2220
- if (trimmedSize !== "small" && trimmedSize !== "default") {
2221
- throwConfigError(
2222
- "Assistant button size must be either 'small' or 'default'.",
2223
- ["assistant", "button", "size"],
2224
- );
2225
- }
2226
- assistant.button.size = trimmedSize;
2227
- }
2228
-
2229
- if (assistant.button.color !== undefined) {
2230
- assistant.button.color = normalizeThemeColorConfig(
2231
- assistant.button.color,
2232
- ["assistant", "button", "color"],
2233
- "Assistant button color",
2234
- );
2235
- }
2236
- }
2237
-
2238
- if (assistant.navbarButton !== undefined) {
2239
- checkType(
2240
- assistant.navbarButton,
2241
- "object",
2242
- ["assistant", "navbarButton"],
2243
- "Assistant navbar button configuration",
2244
- );
2245
- if (
2246
- typeof assistant.navbarButton !== "object" ||
2247
- assistant.navbarButton === null ||
2248
- Array.isArray(assistant.navbarButton)
2249
- ) {
2250
- throwConfigError(
2251
- "Assistant navbar button configuration must be an object.",
2252
- ["assistant", "navbarButton"],
2253
- );
2254
- }
2255
-
2256
- const allowedNavbarButtonKeys = new Set(["enabled", "text", "color"]);
2257
- for (const key of Object.keys(assistant.navbarButton)) {
2258
- if (!allowedNavbarButtonKeys.has(key)) {
2259
- throwConfigError(
2260
- "Assistant navbar button configuration only supports 'enabled', 'text', and 'color'.",
2261
- ["assistant", "navbarButton", key],
2262
- );
2263
- }
2264
- }
2265
-
2266
- if (assistant.navbarButton.enabled !== undefined) {
2267
- checkType(
2268
- assistant.navbarButton.enabled,
2269
- "boolean",
2270
- ["assistant", "navbarButton", "enabled"],
2271
- "Assistant navbar button enabled",
2272
- );
2273
- if (typeof assistant.navbarButton.enabled !== "boolean") {
2274
- throwConfigError(
2275
- "Assistant navbar button enabled must be a boolean.",
2276
- ["assistant", "navbarButton", "enabled"],
2277
- );
2278
- }
2279
- }
2280
-
2281
- if (assistant.navbarButton.text !== undefined) {
2282
- checkType(
2283
- assistant.navbarButton.text,
2284
- "string",
2285
- ["assistant", "navbarButton", "text"],
2286
- "Assistant navbar button text",
2287
- );
2288
- if (typeof assistant.navbarButton.text !== "string") {
2289
- throwConfigError("Assistant navbar button text must be a string.", [
2290
- "assistant",
2291
- "navbarButton",
2292
- "text",
2293
- ]);
2294
- }
2295
-
2296
- const trimmedText = assistant.navbarButton.text.trim();
2297
- if (trimmedText.length === 0) {
2298
- throwConfigError("Assistant navbar button text cannot be empty.", [
2299
- "assistant",
2300
- "navbarButton",
2301
- "text",
2302
- ]);
2303
- }
2304
- assistant.navbarButton.text = trimmedText;
2305
- }
2306
-
2307
- if (assistant.navbarButton.color !== undefined) {
2308
- assistant.navbarButton.color = normalizeThemeColorConfig(
2309
- assistant.navbarButton.color,
2310
- ["assistant", "navbarButton", "color"],
2311
- "Assistant navbar button color",
2312
- );
2313
- }
2314
- }
2315
-
2316
- if (assistant.heading !== undefined) {
2317
- checkType(
2318
- assistant.heading,
2319
- "string",
2320
- ["assistant", "heading"],
2321
- "Assistant heading",
2322
- );
2323
- if (typeof assistant.heading !== "string") {
2324
- throwConfigError("Assistant heading must be a string.", [
2325
- "assistant",
2326
- "heading",
2327
- ]);
2328
- }
2329
-
2330
- const trimmedHeading = assistant.heading.trim();
2331
- if (trimmedHeading.length === 0) {
2332
- throwConfigError("Assistant heading cannot be empty.", [
2333
- "assistant",
2334
- "heading",
2335
- ]);
2336
- }
2337
- assistant.heading = trimmedHeading;
2338
- }
2339
-
2340
- if (assistant.questions !== undefined) {
2341
- checkType(
2342
- assistant.questions,
2343
- "array",
2344
- ["assistant", "questions"],
2345
- "Assistant questions",
2346
- );
2347
- if (!Array.isArray(assistant.questions)) {
2348
- throwConfigError("Assistant questions must be an array.", [
2349
- "assistant",
2350
- "questions",
2351
- ]);
2352
- }
2353
-
2354
- if (assistant.questions.length > 3) {
2355
- throwConfigError("Assistant questions can include at most 3 questions.", [
2356
- "assistant",
2357
- "questions",
2358
- ]);
2359
- }
2360
-
2361
- assistant.questions = assistant.questions.map((question, index) => {
2362
- checkType(
2363
- question,
2364
- "string",
2365
- ["assistant", "questions", String(index)],
2366
- "Assistant question",
2367
- );
2368
- if (typeof question !== "string") {
2369
- throwConfigError("Assistant question must be a string.", [
2370
- "assistant",
2371
- "questions",
2372
- String(index),
2373
- ]);
2374
- }
2375
-
2376
- const trimmedQuestion = question.trim();
2377
- if (trimmedQuestion.length === 0) {
2378
- throwConfigError(
2379
- "Assistant question cannot be empty.",
2380
- ["assistant", "questions", String(index)],
2381
- );
2382
- }
2383
-
2384
- return trimmedQuestion;
2385
- });
2386
- }
2387
-
2388
- if (assistant.icon === undefined) return;
2389
-
2390
- checkType(
2391
- assistant.icon,
2392
- "object",
2393
- ["assistant", "icon"],
2394
- "Assistant icon",
2395
- );
2396
- if (
2397
- typeof assistant.icon !== "object" ||
2398
- assistant.icon === null ||
2399
- Array.isArray(assistant.icon)
2400
- ) {
2401
- throwConfigError("Assistant icon must be an object.", [
2402
- "assistant",
2403
- "icon",
2404
- ]);
2405
- }
2406
-
2407
- const allowedIconKeys = new Set(["src"]);
2408
- for (const key of Object.keys(assistant.icon)) {
2409
- if (!allowedIconKeys.has(key)) {
2410
- throwConfigError(
2411
- "Assistant icon only supports 'src'.",
2412
- ["assistant", "icon", key],
2413
- );
2414
- }
2415
- }
2416
-
2417
- if (assistant.icon.src === undefined) {
2418
- throwConfigError(
2419
- "Assistant icon must include 'src'.",
2420
- ["assistant", "icon"],
2421
- );
2422
- }
2423
-
2424
- if (assistant.icon.src !== undefined) {
2425
- assistant.icon.src = validateAssistantIconSource(
2426
- assistant.icon.src,
2427
- ["assistant", "icon", "src"],
2428
- );
2429
- }
2430
-
2431
- }
2432
-
2433
- function validateHome(home: DocsConfig["home"]): string | undefined {
2434
- if (home === undefined) return undefined;
2435
-
2436
- const normalizedHome = normalizeDocsPagePath(home, ["home"], "Home path");
2437
- validateFileExistence(normalizedHome, ["home"]);
2438
- return normalizedHome;
2439
- }
2440
-
2441
- function validateNavbar(navbar: DocsConfig["navbar"]): HiddenPageRoute[] {
2442
- const hiddenPageRoutes: HiddenPageRoute[] = [];
2443
- if (navbar === undefined) return hiddenPageRoutes; // Navbar itself is optional
2444
-
2445
- checkType(navbar, "object", ["navbar"], "Navbar configuration");
2446
-
2447
- // Validate 'blur'
2448
- checkType(navbar.blur, "boolean", ["navbar", "blur"], "Navbar blur setting");
2449
-
2450
- // Validate 'primary' item
2451
- const primaryPageRoute = validateNavbarItem(navbar.primary, [
2452
- "navbar",
2453
- "primary",
2454
- ]);
2455
- if (primaryPageRoute) hiddenPageRoutes.push(primaryPageRoute);
2456
-
2457
- // Validate 'secondary' item
2458
- const secondaryPageRoute = validateNavbarItem(navbar.secondary, [
2459
- "navbar",
2460
- "secondary",
2461
- ]);
2462
- if (secondaryPageRoute) hiddenPageRoutes.push(secondaryPageRoute);
2463
-
2464
- // Validate 'links' array
2465
- if (navbar.links !== undefined) {
2466
- checkType(navbar.links, "array", ["navbar", "links"], "Navbar links");
2467
-
2468
- if (navbar.links.length > 3) {
2469
- throwConfigError("Navbar links cannot have more than 3 items.", [
2470
- "navbar",
2471
- "links",
2472
- ]);
2473
- }
2474
-
2475
- navbar.links.forEach((link: any, i: number) => {
2476
- const hiddenPageRoute = validateNavbarItem(link, ["navbar", "links", i]);
2477
- if (hiddenPageRoute) hiddenPageRoutes.push(hiddenPageRoute);
2478
- });
2479
- }
2480
-
2481
- return hiddenPageRoutes;
2482
- }
2483
-
2484
- function validateFooter(footer: DocsConfig["footer"]): HiddenPageRoute[] {
2485
- const hiddenPageRoutes: HiddenPageRoute[] = [];
2486
- if (footer === undefined) return hiddenPageRoutes;
2487
-
2488
- checkType(footer, "object", ["footer"], "Footer configuration");
2489
-
2490
- // Validate socials
2491
- if (footer.socials !== undefined) {
2492
- checkType(
2493
- footer.socials,
2494
- "object",
2495
- ["footer", "socials"],
2496
- "Footer socials",
2497
- );
2498
- const validSocials = [
2499
- "x",
2500
- "website",
2501
- "facebook",
2502
- "youtube",
2503
- "discord",
2504
- "slack",
2505
- "github",
2506
- "linkedin",
2507
- "instagram",
2508
- "hacker-news",
2509
- "medium",
2510
- "telegram",
2511
- "bluesky",
2512
- "threads",
2513
- "reddit",
2514
- "podcast",
2515
- ];
2516
- for (const [key, value] of Object.entries(footer.socials)) {
2517
- if (!validSocials.includes(key)) {
2518
- throwConfigError(
2519
- `Invalid social platform: ${key}. Valid options are: ${validSocials.join(
2520
- ", ",
2521
- )}`,
2522
- ["footer", "socials", key],
2523
- );
2524
- }
2525
- checkType(
2526
- value,
2527
- "string",
2528
- ["footer", "socials", key],
2529
- `Social link for ${key}`,
2530
- );
2531
- if (!isUrl(value as string)) {
2532
- throwConfigError(`Social link for ${key} must be a valid URL.`, [
2533
- "footer",
2534
- "socials",
2535
- key,
2536
- ]);
2537
- }
2538
- }
2539
- }
2540
-
2541
- // Validate links
2542
- if (footer.links !== undefined) {
2543
- checkType(footer.links, "array", ["footer", "links"], "Footer links");
2544
- footer.links.forEach((link: any, i: number) => {
2545
- checkType(link, "object", ["footer", "links", i], "Footer link");
2546
-
2547
- if (typeof link.text !== "string") {
2548
- throwConfigError("Footer link must have a 'text' property.", [
2549
- "footer",
2550
- "links",
2551
- i,
2552
- "text",
2553
- ]);
2554
- }
2555
-
2556
- if (typeof link.href !== "string") {
2557
- throwConfigError("Footer link must have an 'href' property.", [
2558
- "footer",
2559
- "links",
2560
- i,
2561
- "href",
2562
- ]);
2563
- }
2564
-
2565
- const hiddenPageRoute = normalizeInternalPageHref(
2566
- link.href,
2567
- ["footer", "links", i, "href"],
2568
- "Footer link href",
2569
- );
2570
- if (hiddenPageRoute) {
2571
- link.href = hiddenPageRoute.linkHref;
2572
- hiddenPageRoutes.push(hiddenPageRoute);
2573
- }
2574
- });
2575
- }
2576
-
2577
- return hiddenPageRoutes;
2578
- }
2579
-
2580
- async function validateNavigation(navigation: DocsConfig["navigation"]) {
2581
- checkType(navigation, "object", ["navigation"], "Navigation");
2582
-
2583
- const keys = Object.keys(navigation);
2584
- const validKeys = ["pages", "menu", "openapi"];
2585
- const navKeys = keys.filter((key) => validKeys.includes(key));
2586
-
2587
- if (navKeys.length !== 1) {
2588
- throwConfigError(
2589
- `Navigation must contain exactly one top-level item (${validKeys.join(
2590
- ", ",
2591
- )}). Found ${navKeys.length}.`,
2592
- ["navigation"],
2593
- );
2594
- }
2595
-
2596
- const navKey = navKeys[0];
2597
- const navValue = (navigation as any)[navKey];
2598
-
2599
- // Handle "menu" as an object, "pages" as an array
2600
- if (navKey === "menu") {
2601
- await validateNavMenu(navValue, ["navigation", "menu"]);
2602
- } else {
2603
- // Validate the container itself is an array for pages
2604
- checkType(
2605
- navValue,
2606
- "array",
2607
- ["navigation", navKey],
2608
- `Navigation container '${navKey}'`,
2609
- );
2610
-
2611
- // Route to Recursive Structural Validation
2612
- for (const [i, item] of navValue.entries()) {
2613
- const itemPath = ["navigation", navKey, i] as Path;
2614
- if (typeof item === "string") {
2615
- const normalizedPagePath = normalizeDocsPagePath(item, itemPath);
2616
- navValue[i] = normalizedPagePath;
2617
- validateFileExistence(normalizedPagePath, itemPath);
2618
- } else {
2619
- await validateNavigationNode(item, itemPath);
2620
- }
2621
- }
2622
- }
2623
- }
2624
-
2625
- // --- Config Runner ---
2626
-
2627
- async function validateConfig(config: any): Promise<DocsConfig> {
2628
- // Execute top-level checks sequentially
2629
- validateTitle(config.title);
2630
- validateLogo(config.logo);
2631
- validateTheme(config.theme);
2632
- validateAssistant(config.assistant);
2633
- await validateNavigation(config.navigation);
2634
- config.home = validateHome(config.home);
2635
-
2636
- if (config.home === undefined) {
2637
- const fallbackHome = getFirstPagePathFromNavigation(config.navigation);
2638
- if (!fallbackHome) {
2639
- throwConfigError(
2640
- "Home is undefined and no documentation page exists in navigation to use as fallback.",
2641
- ["home"],
2642
- );
2643
- }
2644
- config.home = fallbackHome;
2645
- }
2646
-
2647
- const hiddenPageRoutes = [
2648
- ...validateNavbar(config.navbar),
2649
- ...validateFooter(config.footer),
2650
- ];
2651
- const dedupedHiddenPageRoutes = new Map<string, HiddenPageRoute>();
2652
- for (const route of hiddenPageRoutes) {
2653
- dedupedHiddenPageRoutes.set(route.href, route);
2654
- }
2655
- config.hiddenPageRoutes = Array.from(dedupedHiddenPageRoutes.values());
2656
-
2657
- // --- 4. Validate Playground ---
2658
- if (config.playground !== undefined) {
2659
- checkType(config.playground, "object", ["playground"], "Playground");
2660
- if (config.playground.proxy !== undefined) {
2661
- checkType(
2662
- config.playground.proxy,
2663
- "boolean",
2664
- ["playground", "proxy"],
2665
- "Proxy",
2666
- );
2667
- }
2668
- }
2669
-
2670
- return config as DocsConfig;
2671
- }
2672
-
2673
- let configCache: Promise<DocsConfig> | null = null;
2674
- let lastMtime: number = 0;
2675
-
2676
- export async function getConfig(): Promise<DocsConfig> {
2677
- // 1. Check if docs.json exists
2678
- if (!fs.existsSync(CONFIG_PATH)) {
2679
- throw new Error(
2680
- "[USER_ERROR]: Invalid docs.json: `docs.json` missing at root of documentation repo.",
2681
- );
2682
- }
2683
-
2684
- // 2. Check if docs.json has changed
2685
- const stats = fs.statSync(CONFIG_PATH);
2686
- if (configCache && stats.mtimeMs === lastMtime) {
2687
- return configCache;
2688
- }
2689
-
2690
- // 3. If docs.json changed or first run, update cache
2691
- lastMtime = stats.mtimeMs;
2692
- configCache = (async () => {
2693
- const fileContent = fs.readFileSync(CONFIG_PATH, "utf-8");
2694
- let config: any;
2695
- try {
2696
- config = JSON.parse(fileContent);
2697
- } catch (e) {
2698
- throw new Error(
2699
- `[USER_ERROR]: Invalid docs.json: Invalid JSON syntax: ${
2700
- e instanceof Error ? e.message : e
2701
- }`,
2702
- );
2703
- }
2704
- // ---
2705
-
2706
- // The custom validation is executed here
2707
- try {
2708
- const validatedConfig = await validateConfig(config);
2709
- return validatedConfig;
2710
- } catch (error) {
2711
- // Catch the custom error thrown by throwConfigError and re-throw it.
2712
- throw new Error(
2713
- `[USER_ERROR]: Invalid docs.json: ${
2714
- error instanceof Error ? error.message : error
2715
- }`,
2716
- );
2717
- }
2718
- })();
2719
-
2720
- return configCache;
2721
- }
2722
-
2723
- // Validate that only known components are used in MDX content
2724
- function validateComponentUsage(content: string): void {
2725
- // Remove frontmatter before checking
2726
- const contentWithoutFrontmatter = content.replace(/^---[\s\S]*?---\n?/, "");
2727
- const allowedComponentSet = new Set(AVAILABLE_COMPONENTS);
2728
-
2729
- // Remove code blocks, inline code, and JSX string expressions to avoid false positives
2730
- const contentWithoutCode = contentWithoutFrontmatter
2731
- .replace(/````[\s\S]*?````/g, "") // 4-backtick code blocks (check first, they're longer)
2732
- .replace(/```[\s\S]*?```/g, "") // fenced code blocks
2733
- .replace(/`[^`]+`/g, "") // inline code
2734
- // JSX string expressions like {'<Component title="Example" />'} or {"<Component />"}
2735
- .replace(/\{\s*(['"`])(?:\\.|(?!\1)[\s\S])*?\1\s*\}/g, "");
2736
-
2737
- // Find all JSX component tags (PascalCase tags)
2738
- // Matches: <ComponentName, <ComponentName>, <ComponentName />, etc.
2739
- const componentRegex = /<([A-Z][a-zA-Z0-9]*)/g;
2740
- let match;
2741
-
2742
- const unknownComponents: string[] = [];
2743
-
2744
- while ((match = componentRegex.exec(contentWithoutCode)) !== null) {
2745
- const componentName = match[1];
2746
- if (!allowedComponentSet.has(componentName)) {
2747
- // Avoid duplicate entries
2748
- if (!unknownComponents.includes(componentName)) {
2749
- unknownComponents.push(componentName);
2750
- }
2751
- }
2752
- }
2753
-
2754
- if (unknownComponents.length > 0) {
2755
- const componentList = unknownComponents.map((c) => `<${c}>`).join(", ");
2756
- const visibleComponents = AVAILABLE_COMPONENTS.filter(
2757
- (component) => !INTERNAL_ONLY_COMPONENTS.has(component),
2758
- );
2759
- throw new Error(
2760
- `Unknown component(s): ${componentList}. ` +
2761
- `Available components are: ${visibleComponents.join(", ")}. ` +
2762
- "If writing ABOUT a component, use literal backticks: " +
2763
- "\`<ComponentName>\` or a JSX string: " +
2764
- "\`{'<ComponentName />'}\`.",
2765
- );
2766
- }
2767
- }
2768
-
2769
- // Helper to recursively find MDX files
2770
- function getMdxFiles(dir: string): string[] {
2771
- let results: string[] = [];
2772
- const list = fs.readdirSync(dir);
2773
- list.forEach((file) => {
2774
- file = path.resolve(dir, file);
2775
- const stat = fs.statSync(file);
2776
- if (stat && stat.isDirectory()) {
2777
- results = results.concat(getMdxFiles(file));
2778
- } else if (file.endsWith(".mdx")) {
2779
- results.push(file);
2780
- }
2781
- });
2782
- return results;
2783
- }
2784
-
2785
- // MDX Validation Function
2786
- export async function validateMdxContent() {
2787
- const files = getMdxFiles(DOCS_DIR);
2788
-
2789
- for (const file of files) {
2790
- try {
2791
- const content = fs.readFileSync(file, "utf-8");
2792
-
2793
- // Check for Frontmatter
2794
- const match = content.match(/^---\n([\s\S]*?)\n---/);
2795
-
2796
- if (match) {
2797
- // Parse only if it exists
2798
- const frontmatter = yaml.parse(match[1]);
2799
-
2800
- // Validate against shared schema
2801
- const result = docsSchema.safeParse(frontmatter);
2802
-
2803
- if (!result.success) {
2804
- const issue = result.error.issues[0];
2805
- const pathStr = issue.path.join(".");
2806
- // Throw clean error
2807
- throw new Error(
2808
- `Frontmatter validation failed: ${issue.message} (at: ${pathStr})`,
2809
- );
2810
- }
2811
- }
2812
-
2813
- // Validate component usage BEFORE compiling
2814
- validateComponentUsage(content);
2815
-
2816
- // Compile just to check syntax
2817
- await compile(content, { jsx: true });
2818
- } catch (e: any) {
2819
- const relativePath = path.relative(DOCS_DIR, file);
2820
- const location = e.line ? `:${e.line}:${e.column}` : "";
2821
- const reason = e.reason || e.message;
2822
-
2823
- // Throw clean error
2824
- throw new Error(
2825
- `[USER_ERROR]: Invalid MDX in ${relativePath}${location} -> ${reason}`,
2826
- );
2827
- }
2828
- }
2829
- }
8
+ export * from "radiant-docs-validator";