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