radiant-docs 0.1.34 → 0.1.38
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/package.json +1 -1
- package/template/astro.config.mjs +27 -0
- package/template/package-lock.json +1027 -513
- package/template/package.json +3 -2
- package/template/scripts/generate-proxy-allowed-origins.mjs +217 -0
- package/template/scripts/generate-robots-txt.mjs +19 -0
- package/template/scripts/stamp-image-versions.mjs +63 -11
- package/template/src/components/Footer.astro +1 -1
- package/template/src/components/Header.astro +9 -9
- package/template/src/components/LogoLink.astro +2 -1
- package/template/src/components/OpenApiPage.astro +18 -18
- package/template/src/components/Search.astro +18 -18
- package/template/src/components/Sidebar.astro +4 -2
- package/template/src/components/SidebarDropdown.astro +82 -79
- package/template/src/components/SidebarGroup.astro +3 -0
- package/template/src/components/SidebarMenu.astro +14 -1
- package/template/src/components/SidebarSegmented.astro +5 -5
- package/template/src/components/SidebarSubgroup.astro +35 -12
- package/template/src/components/TableOfContents.astro +24 -15
- package/template/src/components/ThemeSwitcher.astro +15 -8
- package/template/src/components/chat/AskAiWidget.tsx +10 -5
- package/template/src/components/endpoint/PlaygroundBar.astro +3 -3
- package/template/src/components/endpoint/PlaygroundButton.astro +3 -3
- package/template/src/components/endpoint/PlaygroundField.astro +53 -53
- package/template/src/components/endpoint/PlaygroundForm.astro +51 -37
- package/template/src/components/endpoint/RequestSnippets.astro +54 -21
- package/template/src/components/endpoint/ResponseDisplay.astro +24 -24
- package/template/src/components/endpoint/ResponseFieldTree.astro +12 -12
- package/template/src/components/endpoint/ResponseFields.astro +19 -19
- package/template/src/components/endpoint/ResponseSnippets.astro +66 -29
- package/template/src/components/sidebar/SidebarEndpointLink.astro +18 -15
- package/template/src/components/sidebar/SidebarOpenApiPageLink.astro +56 -0
- package/template/src/components/ui/CodeTabEdge.astro +6 -4
- package/template/src/components/ui/Field.astro +7 -7
- package/template/src/components/ui/Icon.astro +2 -1
- package/template/src/components/ui/demo/Demo.astro +1 -1
- package/template/src/components/user/Accordion.astro +3 -3
- package/template/src/components/user/Callout.astro +8 -8
- package/template/src/components/user/CodeBlock.astro +57 -22
- package/template/src/components/user/CodeGroup.astro +14 -10
- package/template/src/components/user/ComponentPreviewBlock.astro +38 -12
- package/template/src/components/user/Image.astro +6 -2
- package/template/src/components/user/Step.astro +4 -4
- package/template/src/components/user/Tab.astro +1 -1
- package/template/src/components/user/Tabs.astro +15 -20
- package/template/src/layouts/Layout.astro +9 -4
- package/template/src/lib/code/code-block.ts +150 -15
- package/template/src/lib/mdx/remark-resolve-internal-links.ts +639 -0
- package/template/src/lib/pagefind.ts +2 -1
- package/template/src/lib/routes.ts +134 -58
- package/template/src/lib/static-asset-url.ts +62 -0
- package/template/src/lib/utils.ts +48 -0
- package/template/src/lib/validation.ts +115 -27
- package/template/src/pages/404.astro +44 -0
- package/template/src/styles/global.css +28 -19
- package/template/scripts/rewrite-static-asset-host.mjs +0 -408
|
@@ -2,17 +2,20 @@ import {
|
|
|
2
2
|
getConfig,
|
|
3
3
|
type NavGroup,
|
|
4
4
|
type NavPage,
|
|
5
|
+
type NavOpenApiPage,
|
|
5
6
|
type NavMenuItem,
|
|
6
7
|
type NavOpenApi,
|
|
7
8
|
loadOpenApiSpec,
|
|
8
9
|
} from "./validation";
|
|
9
|
-
import {
|
|
10
|
-
|
|
10
|
+
import {
|
|
11
|
+
buildOpenApiEndpointSlug,
|
|
12
|
+
deriveTitleFromEntryId,
|
|
13
|
+
parseOpenApiEndpoint,
|
|
14
|
+
slugify,
|
|
15
|
+
} from "./utils";
|
|
11
16
|
import { getCollection } from "astro:content";
|
|
12
17
|
|
|
13
|
-
|
|
14
|
-
// Since the input array is already validated, we can use the types from validation.ts
|
|
15
|
-
type NavPageItem = string | NavPage;
|
|
18
|
+
type MdxNavPageItem = string | NavPage;
|
|
16
19
|
|
|
17
20
|
// Base route interface
|
|
18
21
|
export interface BaseRoute {
|
|
@@ -55,7 +58,7 @@ export function resolveMdxPageTitle(args: {
|
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
function processPageItem(
|
|
58
|
-
item:
|
|
61
|
+
item: MdxNavPageItem,
|
|
59
62
|
parentSlug: string = "",
|
|
60
63
|
docs: any[],
|
|
61
64
|
): MdxRoute {
|
|
@@ -92,53 +95,106 @@ function processPageItem(
|
|
|
92
95
|
};
|
|
93
96
|
}
|
|
94
97
|
|
|
95
|
-
|
|
98
|
+
type OpenApiOperationLookup = {
|
|
99
|
+
method: string;
|
|
100
|
+
path: string;
|
|
101
|
+
operation: any;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function findOpenApiOperation(args: {
|
|
105
|
+
openApiDoc: any;
|
|
106
|
+
endpointMethod: string;
|
|
107
|
+
endpointPath: string;
|
|
108
|
+
}): OpenApiOperationLookup | null {
|
|
109
|
+
const paths = args.openApiDoc?.paths ?? {};
|
|
110
|
+
const targetMethod = args.endpointMethod.toLowerCase();
|
|
111
|
+
const targetPath = args.endpointPath.toLowerCase();
|
|
112
|
+
|
|
113
|
+
for (const [pathStr, pathItem] of Object.entries(paths)) {
|
|
114
|
+
if (!pathItem || typeof pathItem !== "object") continue;
|
|
115
|
+
if (pathStr.toLowerCase() !== targetPath) continue;
|
|
116
|
+
|
|
117
|
+
const operation = (pathItem as any)[targetMethod];
|
|
118
|
+
if (!operation) continue;
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
method: targetMethod,
|
|
122
|
+
path: pathStr,
|
|
123
|
+
operation,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function processOpenApiPageItem(
|
|
131
|
+
item: NavOpenApiPage,
|
|
132
|
+
parentSlug: string = "",
|
|
133
|
+
): Promise<OpenApiRoute> {
|
|
134
|
+
const parsedEndpoint = parseOpenApiEndpoint(item.openapi.endpoint);
|
|
135
|
+
if (!parsedEndpoint) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Invalid OpenAPI endpoint format "${item.openapi.endpoint}". Expected "METHOD /path".`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const openApiDoc = await loadOpenApiSpec(item.openapi.source);
|
|
142
|
+
const matchedOperation = findOpenApiOperation({
|
|
143
|
+
openApiDoc,
|
|
144
|
+
endpointMethod: parsedEndpoint.method,
|
|
145
|
+
endpointPath: parsedEndpoint.path,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (!matchedOperation) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`OpenAPI endpoint "${item.openapi.endpoint}" was not found in source "${item.openapi.source}".`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const endpointSlug = buildOpenApiEndpointSlug(
|
|
155
|
+
matchedOperation.path,
|
|
156
|
+
matchedOperation.method,
|
|
157
|
+
);
|
|
158
|
+
const fullSlug = parentSlug ? `${parentSlug}/${endpointSlug}` : endpointSlug;
|
|
159
|
+
const title =
|
|
160
|
+
normalizeTitle(item.title) ??
|
|
161
|
+
normalizeTitle(matchedOperation.operation.summary) ??
|
|
162
|
+
`${matchedOperation.method.toUpperCase()} ${matchedOperation.path}`;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
type: "openapi",
|
|
166
|
+
slug: fullSlug,
|
|
167
|
+
filePath: item.openapi.source,
|
|
168
|
+
title,
|
|
169
|
+
openApiPath: matchedOperation.path,
|
|
170
|
+
openApiMethod: matchedOperation.method,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function processGroup(
|
|
96
175
|
group: NavGroup,
|
|
97
176
|
parentSlug: string = "",
|
|
98
177
|
docs: any[],
|
|
99
|
-
): Route[] {
|
|
178
|
+
): Promise<Route[]> {
|
|
100
179
|
let routes: Route[] = [];
|
|
101
180
|
const groupSlug = slugify(group.group);
|
|
102
181
|
const currentPrefix = parentSlug ? `${parentSlug}/${groupSlug}` : groupSlug;
|
|
103
182
|
|
|
104
|
-
group.pages
|
|
183
|
+
for (const item of group.pages) {
|
|
105
184
|
// Check if it's a simple page or a page object
|
|
106
185
|
if (typeof item === "string" || "page" in item) {
|
|
107
|
-
routes.push(processPageItem(item
|
|
186
|
+
routes.push(processPageItem(item, currentPrefix, docs));
|
|
108
187
|
} else if ("group" in item) {
|
|
109
188
|
// Nested group
|
|
110
189
|
routes = routes.concat(
|
|
111
|
-
processGroup(item as NavGroup, currentPrefix, docs),
|
|
190
|
+
await processGroup(item as NavGroup, currentPrefix, docs),
|
|
112
191
|
);
|
|
192
|
+
} else if ("openapi" in item) {
|
|
193
|
+
routes.push(await processOpenApiPageItem(item, currentPrefix));
|
|
113
194
|
}
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
return routes;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Helper function to parse endpoint string (same as in validation.ts)
|
|
120
|
-
function parseEndpointString(
|
|
121
|
-
endpointStr: string,
|
|
122
|
-
): { method: string; path: string } | null {
|
|
123
|
-
const trimmed = endpointStr.trim();
|
|
124
|
-
const parts = trimmed.split(/\s+/);
|
|
125
|
-
|
|
126
|
-
if (parts.length !== 2) {
|
|
127
|
-
return null;
|
|
128
195
|
}
|
|
129
196
|
|
|
130
|
-
|
|
131
|
-
let path = parts[1];
|
|
132
|
-
|
|
133
|
-
// Ensure path starts with /
|
|
134
|
-
if (!path.startsWith("/")) {
|
|
135
|
-
path = "/" + path;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Normalize path to lowercase for comparison
|
|
139
|
-
const normalizedPath = path.toLowerCase();
|
|
140
|
-
|
|
141
|
-
return { method, path: normalizedPath };
|
|
197
|
+
return routes;
|
|
142
198
|
}
|
|
143
199
|
|
|
144
200
|
// Helper function to check if an endpoint matches include/exclude filters
|
|
@@ -156,7 +212,7 @@ function shouldIncludeEndpoint(
|
|
|
156
212
|
// If include is specified, only include matching endpoints
|
|
157
213
|
if (include) {
|
|
158
214
|
return include.some((entry) => {
|
|
159
|
-
const parsed =
|
|
215
|
+
const parsed = parseOpenApiEndpoint(entry);
|
|
160
216
|
if (!parsed) return false;
|
|
161
217
|
return `${parsed.method} ${parsed.path}` === endpointKey;
|
|
162
218
|
});
|
|
@@ -165,7 +221,7 @@ function shouldIncludeEndpoint(
|
|
|
165
221
|
// If exclude is specified, exclude matching endpoints
|
|
166
222
|
if (exclude) {
|
|
167
223
|
return !exclude.some((entry) => {
|
|
168
|
-
const parsed =
|
|
224
|
+
const parsed = parseOpenApiEndpoint(entry);
|
|
169
225
|
if (!parsed) return false;
|
|
170
226
|
return `${parsed.method} ${parsed.path}` === endpointKey;
|
|
171
227
|
});
|
|
@@ -180,8 +236,6 @@ async function processOpenApiFile(
|
|
|
180
236
|
parentSlug: string = "",
|
|
181
237
|
): Promise<OpenApiRoute[]> {
|
|
182
238
|
const routes: OpenApiRoute[] = [];
|
|
183
|
-
const CWD = process.cwd();
|
|
184
|
-
const DOCS_DIR = path.join(CWD, "src/content/docs");
|
|
185
239
|
|
|
186
240
|
// Extract file path and filter options
|
|
187
241
|
let openApiPath: string;
|
|
@@ -232,15 +286,7 @@ async function processOpenApiFile(
|
|
|
232
286
|
continue;
|
|
233
287
|
}
|
|
234
288
|
|
|
235
|
-
|
|
236
|
-
// e.g., "/burgers" + "get" -> "burgers-get"
|
|
237
|
-
const pathSlug = pathStr
|
|
238
|
-
.replace(/^\//, "") // Remove leading slash
|
|
239
|
-
.replace(/\//g, "-") // Replace slashes with hyphens
|
|
240
|
-
.replace(/\{[^}]+\}/g, "param"); // Replace path params like {id} with "param"
|
|
241
|
-
|
|
242
|
-
const methodSlug = slugify(method);
|
|
243
|
-
const endpointSlug = pathSlug ? `${pathSlug}-${methodSlug}` : methodSlug;
|
|
289
|
+
const endpointSlug = buildOpenApiEndpointSlug(pathStr, method);
|
|
244
290
|
const fullSlug = parentSlug
|
|
245
291
|
? `${parentSlug}/${endpointSlug}`
|
|
246
292
|
: endpointSlug;
|
|
@@ -277,15 +323,17 @@ async function processMenuItem(
|
|
|
277
323
|
|
|
278
324
|
// Process pages if they exist (pages array can contain pages and groups)
|
|
279
325
|
if (submenu.pages) {
|
|
280
|
-
submenu.pages
|
|
326
|
+
for (const item of submenu.pages) {
|
|
281
327
|
if (typeof item === "string" || "page" in item) {
|
|
282
|
-
routes.push(processPageItem(item
|
|
328
|
+
routes.push(processPageItem(item, currentPrefix, docs));
|
|
283
329
|
} else if ("group" in item) {
|
|
284
330
|
routes = routes.concat(
|
|
285
|
-
processGroup(item as NavGroup, currentPrefix, docs),
|
|
331
|
+
await processGroup(item as NavGroup, currentPrefix, docs),
|
|
286
332
|
);
|
|
333
|
+
} else if ("openapi" in item) {
|
|
334
|
+
routes.push(await processOpenApiPageItem(item, currentPrefix));
|
|
287
335
|
}
|
|
288
|
-
}
|
|
336
|
+
}
|
|
289
337
|
}
|
|
290
338
|
|
|
291
339
|
// Process OpenAPI file if it exists
|
|
@@ -300,6 +348,31 @@ async function processMenuItem(
|
|
|
300
348
|
return routes;
|
|
301
349
|
}
|
|
302
350
|
|
|
351
|
+
function assertUniqueRouteSlugs(routes: Route[]): void {
|
|
352
|
+
const seenBySlug = new Map<string, Route>();
|
|
353
|
+
|
|
354
|
+
for (const route of routes) {
|
|
355
|
+
const existing = seenBySlug.get(route.slug);
|
|
356
|
+
if (!existing) {
|
|
357
|
+
seenBySlug.set(route.slug, route);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const existingLabel =
|
|
362
|
+
existing.type === "mdx"
|
|
363
|
+
? `mdx:${existing.filePath}`
|
|
364
|
+
: `openapi:${existing.filePath}:${existing.openApiMethod.toUpperCase()} ${existing.openApiPath}`;
|
|
365
|
+
const candidateLabel =
|
|
366
|
+
route.type === "mdx"
|
|
367
|
+
? `mdx:${route.filePath}`
|
|
368
|
+
: `openapi:${route.filePath}:${route.openApiMethod.toUpperCase()} ${route.openApiPath}`;
|
|
369
|
+
|
|
370
|
+
throw new Error(
|
|
371
|
+
`Duplicate route slug "${route.slug}" generated by "${existingLabel}" and "${candidateLabel}".`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
303
376
|
export async function getAllRoutes(): Promise<Route[]> {
|
|
304
377
|
// 1. Get the validated config directly
|
|
305
378
|
const config = await getConfig();
|
|
@@ -314,13 +387,15 @@ export async function getAllRoutes(): Promise<Route[]> {
|
|
|
314
387
|
// We use the XOR guarantee from validateNavigation (only one key exists)
|
|
315
388
|
if (navigation.pages) {
|
|
316
389
|
// Case 1: Pages array at the top level (can contain pages and groups)
|
|
317
|
-
navigation.pages
|
|
390
|
+
for (const item of navigation.pages) {
|
|
318
391
|
if (typeof item === "string" || "page" in item) {
|
|
319
|
-
allRoutes.push(processPageItem(item
|
|
392
|
+
allRoutes.push(processPageItem(item, "", docs));
|
|
320
393
|
} else if ("group" in item) {
|
|
321
|
-
allRoutes = allRoutes.concat(processGroup(item as NavGroup, "", docs));
|
|
394
|
+
allRoutes = allRoutes.concat(await processGroup(item as NavGroup, "", docs));
|
|
395
|
+
} else if ("openapi" in item) {
|
|
396
|
+
allRoutes.push(await processOpenApiPageItem(item, ""));
|
|
322
397
|
}
|
|
323
|
-
}
|
|
398
|
+
}
|
|
324
399
|
} else if (navigation.menu) {
|
|
325
400
|
// Case 3: Menu object at the top level
|
|
326
401
|
// Need to await all menu items since processMenuItem is now async
|
|
@@ -330,5 +405,6 @@ export async function getAllRoutes(): Promise<Route[]> {
|
|
|
330
405
|
}
|
|
331
406
|
}
|
|
332
407
|
|
|
408
|
+
assertUniqueRouteSlugs(allRoutes);
|
|
333
409
|
return allRoutes;
|
|
334
410
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
type AssetsPrefixValue = string | Record<string, string> | undefined;
|
|
2
|
+
|
|
3
|
+
function normalizePrefix(value: string): string {
|
|
4
|
+
const normalized = value.trim().replace(/\/+$/, "");
|
|
5
|
+
if (!normalized) return normalized;
|
|
6
|
+
if (hasProtocol(normalized) || normalized.startsWith("//")) {
|
|
7
|
+
return normalized;
|
|
8
|
+
}
|
|
9
|
+
if (normalized.startsWith("/")) {
|
|
10
|
+
return normalized;
|
|
11
|
+
}
|
|
12
|
+
return `https://${normalized}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveAssetsPrefix(value: AssetsPrefixValue): string | null {
|
|
16
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
17
|
+
return normalizePrefix(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!value || typeof value !== "object") {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const fallback = value.fallback;
|
|
25
|
+
if (typeof fallback === "string" && fallback.trim().length > 0) {
|
|
26
|
+
return normalizePrefix(fallback);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const candidate of Object.values(value)) {
|
|
30
|
+
if (typeof candidate === "string" && candidate.trim().length > 0) {
|
|
31
|
+
return normalizePrefix(candidate);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hasProtocol(url: string): boolean {
|
|
39
|
+
return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveStaticAssetUrl(rawPath: string): string {
|
|
43
|
+
const value = rawPath.trim();
|
|
44
|
+
if (!value) return value;
|
|
45
|
+
|
|
46
|
+
if (hasProtocol(value) || value.startsWith("//")) {
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parsed = new URL(value, "https://radiant.invalid");
|
|
51
|
+
const normalizedPathname = parsed.pathname.startsWith("/")
|
|
52
|
+
? parsed.pathname
|
|
53
|
+
: `/${parsed.pathname}`;
|
|
54
|
+
|
|
55
|
+
const prefix = resolveAssetsPrefix(import.meta.env.ASSETS_PREFIX);
|
|
56
|
+
if (!prefix) {
|
|
57
|
+
return `${normalizedPathname}${parsed.search}${parsed.hash}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const normalizedPrefixPath = `${prefix}/${normalizedPathname.replace(/^\/+/, "")}`;
|
|
61
|
+
return `${normalizedPrefixPath}${parsed.search}${parsed.hash}`;
|
|
62
|
+
}
|
|
@@ -79,3 +79,51 @@ export function buildMdxPageHref(args: {
|
|
|
79
79
|
? `/${normalizedGroupSlug}/${pageSlug}`
|
|
80
80
|
: `/${pageSlug}`;
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
export function parseOpenApiEndpoint(
|
|
84
|
+
endpointStr: string,
|
|
85
|
+
): { method: string; path: string } | null {
|
|
86
|
+
const trimmed = endpointStr.trim();
|
|
87
|
+
const parts = trimmed.split(/\s+/);
|
|
88
|
+
|
|
89
|
+
if (parts.length !== 2) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const method = parts[0].toUpperCase();
|
|
94
|
+
let openApiPath = parts[1];
|
|
95
|
+
|
|
96
|
+
if (!openApiPath.startsWith("/")) {
|
|
97
|
+
openApiPath = `/${openApiPath}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
method,
|
|
102
|
+
path: openApiPath.toLowerCase(),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function buildOpenApiEndpointSlug(pathStr: string, method: string): string {
|
|
107
|
+
const normalizedPath = pathStr.startsWith("/") ? pathStr : `/${pathStr}`;
|
|
108
|
+
const pathSlug = normalizedPath
|
|
109
|
+
.replace(/^\//, "")
|
|
110
|
+
.replace(/\//g, "-")
|
|
111
|
+
.replace(/\{[^}]+\}/g, "param");
|
|
112
|
+
const methodSlug = slugify(method);
|
|
113
|
+
return pathSlug ? `${pathSlug}-${methodSlug}` : methodSlug;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildOpenApiEndpointHref(args: {
|
|
117
|
+
path: string;
|
|
118
|
+
method: string;
|
|
119
|
+
groupSlug?: string;
|
|
120
|
+
}): string {
|
|
121
|
+
const endpointSlug = buildOpenApiEndpointSlug(args.path, args.method);
|
|
122
|
+
const normalizedGroupSlug = (args.groupSlug || "")
|
|
123
|
+
.replace(/^\/+/, "")
|
|
124
|
+
.replace(/\/+$/, "");
|
|
125
|
+
|
|
126
|
+
return normalizedGroupSlug
|
|
127
|
+
? `/${normalizedGroupSlug}/${endpointSlug}`
|
|
128
|
+
: `/${endpointSlug}`;
|
|
129
|
+
}
|
|
@@ -125,9 +125,18 @@ export type NavPage = {
|
|
|
125
125
|
tag?: string;
|
|
126
126
|
title?: string;
|
|
127
127
|
};
|
|
128
|
+
export type NavOpenApiPageRef = {
|
|
129
|
+
source: string;
|
|
130
|
+
endpoint: string;
|
|
131
|
+
};
|
|
132
|
+
export type NavOpenApiPage = {
|
|
133
|
+
openapi: NavOpenApiPageRef;
|
|
134
|
+
title?: string;
|
|
135
|
+
tag?: string;
|
|
136
|
+
};
|
|
128
137
|
export type NavGroup = {
|
|
129
138
|
group: string;
|
|
130
|
-
pages: (string | NavPage | NavGroup)[];
|
|
139
|
+
pages: (string | NavPage | NavGroup | NavOpenApiPage)[];
|
|
131
140
|
icon?: string | null;
|
|
132
141
|
expanded?: boolean; // need to add this logic
|
|
133
142
|
tag?: string;
|
|
@@ -138,7 +147,7 @@ export type NavOpenApi = {
|
|
|
138
147
|
exclude?: string[];
|
|
139
148
|
};
|
|
140
149
|
export type NavigationItem = {
|
|
141
|
-
pages?: (string | NavPage | NavGroup)[];
|
|
150
|
+
pages?: (string | NavPage | NavGroup | NavOpenApiPage)[];
|
|
142
151
|
menu?: NavMenu;
|
|
143
152
|
openapi?: string | NavOpenApi;
|
|
144
153
|
};
|
|
@@ -553,11 +562,53 @@ function parseEndpointString(
|
|
|
553
562
|
return { method, path: normalizedPath };
|
|
554
563
|
}
|
|
555
564
|
|
|
556
|
-
function
|
|
565
|
+
async function validateNavOpenApiPage(
|
|
566
|
+
navOpenApiPage: any,
|
|
567
|
+
currentPath: Path,
|
|
568
|
+
): Promise<void> {
|
|
569
|
+
checkType(navOpenApiPage, "object", currentPath, "Open API page");
|
|
570
|
+
|
|
571
|
+
if (typeof navOpenApiPage.source !== "string") {
|
|
572
|
+
throwConfigError(
|
|
573
|
+
"Open API page must include a 'source' property that is a string.",
|
|
574
|
+
[...currentPath, "source"],
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (typeof navOpenApiPage.endpoint !== "string") {
|
|
579
|
+
throwConfigError(
|
|
580
|
+
"Open API page must include an 'endpoint' property that is a string in the format \"METHOD /path\".",
|
|
581
|
+
[...currentPath, "endpoint"],
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const parsedEndpoint = parseEndpointString(navOpenApiPage.endpoint);
|
|
586
|
+
if (!parsedEndpoint) {
|
|
587
|
+
throwConfigError(
|
|
588
|
+
`Open API page endpoint must be in the format "METHOD /path". Found: ${navOpenApiPage.endpoint}`,
|
|
589
|
+
[...currentPath, "endpoint"],
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
await validateOpenApiFile(navOpenApiPage.source, [...currentPath, "source"]);
|
|
594
|
+
|
|
595
|
+
const openApiDoc = await loadOpenApiSpec(navOpenApiPage.source);
|
|
596
|
+
const availableEndpoints = extractAvailableEndpoints(openApiDoc);
|
|
597
|
+
const endpointKey = `${parsedEndpoint!.method} ${parsedEndpoint!.path}`;
|
|
598
|
+
|
|
599
|
+
if (!availableEndpoints.has(endpointKey)) {
|
|
600
|
+
throwConfigError(
|
|
601
|
+
`Open API page endpoint does not match any endpoint in the OpenAPI spec. Found: ${navOpenApiPage.endpoint}. Expected format: "METHOD /path".`,
|
|
602
|
+
[...currentPath, "endpoint"],
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function validateNavigationNode(
|
|
557
608
|
item: any,
|
|
558
609
|
currentPath: Path,
|
|
559
610
|
groupDepth: number = 0,
|
|
560
|
-
): void {
|
|
611
|
+
): Promise<void> {
|
|
561
612
|
// A) Base Case: Simple string path
|
|
562
613
|
if (typeof item === "string") {
|
|
563
614
|
const normalizedPath = normalizeDocsPagePath(item, currentPath);
|
|
@@ -571,11 +622,12 @@ function validateNavigationNode(
|
|
|
571
622
|
// Determine item type by key presence (Strict XOR enforcement)
|
|
572
623
|
const isGroup = "group" in item;
|
|
573
624
|
const isPage = "page" in item;
|
|
625
|
+
const isOpenApiPage = "openapi" in item;
|
|
574
626
|
|
|
575
|
-
const typeCount = [isGroup, isPage].filter(Boolean).length;
|
|
627
|
+
const typeCount = [isGroup, isPage, isOpenApiPage].filter(Boolean).length;
|
|
576
628
|
if (typeCount !== 1) {
|
|
577
629
|
throwConfigError(
|
|
578
|
-
"Object must contain exactly one key: 'page' or '
|
|
630
|
+
"Object must contain exactly one key: 'page', 'group', or 'openapi'.",
|
|
579
631
|
currentPath,
|
|
580
632
|
);
|
|
581
633
|
}
|
|
@@ -601,17 +653,17 @@ function validateNavigationNode(
|
|
|
601
653
|
throwConfigError("Group must have a 'pages' array.", [...path, "pages"]);
|
|
602
654
|
checkType(item.pages, "array", [...path, "pages"], "Group pages");
|
|
603
655
|
|
|
604
|
-
item.pages.
|
|
656
|
+
for (const [i, child] of item.pages.entries()) {
|
|
605
657
|
if (typeof child === "string") {
|
|
606
658
|
const childPath = [...path, "pages", i];
|
|
607
659
|
const normalizedPagePath = normalizeDocsPagePath(child, childPath);
|
|
608
660
|
item.pages[i] = normalizedPagePath;
|
|
609
661
|
validateFileExistence(normalizedPagePath, childPath);
|
|
610
|
-
|
|
662
|
+
continue;
|
|
611
663
|
}
|
|
612
664
|
|
|
613
|
-
validateNavigationNode(child, [...path, "pages", i], groupDepth + 1);
|
|
614
|
-
}
|
|
665
|
+
await validateNavigationNode(child, [...path, "pages", i], groupDepth + 1);
|
|
666
|
+
}
|
|
615
667
|
return;
|
|
616
668
|
}
|
|
617
669
|
|
|
@@ -640,10 +692,47 @@ function validateNavigationNode(
|
|
|
640
692
|
throwConfigError("Page items cannot have children.", [...path, "pages"]);
|
|
641
693
|
return;
|
|
642
694
|
}
|
|
695
|
+
|
|
696
|
+
if (isOpenApiPage) {
|
|
697
|
+
const path = [...currentPath];
|
|
698
|
+
|
|
699
|
+
if ("icon" in item) {
|
|
700
|
+
throwConfigError(
|
|
701
|
+
"Open API page items cannot have an 'icon'. Method badges are displayed automatically.",
|
|
702
|
+
[...path, "icon"],
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
await validateNavOpenApiPage(item.openapi, [...path, "openapi"]);
|
|
707
|
+
checkType(item.title, "string", [...path, "title"], "Open API page title");
|
|
708
|
+
checkType(item.tag, "string", [...path, "tag"], "Open API page tag");
|
|
709
|
+
|
|
710
|
+
if ("expanded" in item)
|
|
711
|
+
throwConfigError("Open API page items cannot have 'expanded'.", [
|
|
712
|
+
...path,
|
|
713
|
+
"expanded",
|
|
714
|
+
]);
|
|
715
|
+
if ("pages" in item)
|
|
716
|
+
throwConfigError("Open API page items cannot have children.", [
|
|
717
|
+
...path,
|
|
718
|
+
"pages",
|
|
719
|
+
]);
|
|
720
|
+
if ("group" in item)
|
|
721
|
+
throwConfigError("Open API page items cannot have 'group'.", [
|
|
722
|
+
...path,
|
|
723
|
+
"group",
|
|
724
|
+
]);
|
|
725
|
+
if ("page" in item)
|
|
726
|
+
throwConfigError("Open API page items cannot have 'page'.", [
|
|
727
|
+
...path,
|
|
728
|
+
"page",
|
|
729
|
+
]);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
643
732
|
}
|
|
644
733
|
|
|
645
734
|
function getFirstPagePathFromPageItems(
|
|
646
|
-
items: (string | NavPage | NavGroup)[],
|
|
735
|
+
items: (string | NavPage | NavGroup | NavOpenApiPage)[],
|
|
647
736
|
): string | undefined {
|
|
648
737
|
for (const item of items) {
|
|
649
738
|
if (typeof item === "string") {
|
|
@@ -890,19 +979,18 @@ async function validateNavMenuItem(item: NavMenuItem, currentPath: Path) {
|
|
|
890
979
|
[...currentPath, "submenu", "pages"],
|
|
891
980
|
"Submenu pages",
|
|
892
981
|
);
|
|
893
|
-
(submenuValue as NavigationItem["pages"])
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
);
|
|
982
|
+
const pages = (submenuValue as NavigationItem["pages"]) ?? [];
|
|
983
|
+
for (const [i, item] of pages.entries()) {
|
|
984
|
+
const itemPath = [...currentPath, "submenu", "pages", i];
|
|
985
|
+
if (typeof item === "string") {
|
|
986
|
+
const normalizedPagePath = normalizeDocsPagePath(item, itemPath);
|
|
987
|
+
(submenuValue as (string | NavPage | NavGroup | NavOpenApiPage)[])[i] =
|
|
988
|
+
normalizedPagePath;
|
|
989
|
+
validateFileExistence(normalizedPagePath, itemPath);
|
|
990
|
+
} else {
|
|
991
|
+
await validateNavigationNode(item, itemPath);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
906
994
|
}
|
|
907
995
|
|
|
908
996
|
// Validate openapi - can be string or NavOpenApi object
|
|
@@ -1326,16 +1414,16 @@ async function validateNavigation(navigation: DocsConfig["navigation"]) {
|
|
|
1326
1414
|
);
|
|
1327
1415
|
|
|
1328
1416
|
// Route to Recursive Structural Validation
|
|
1329
|
-
|
|
1417
|
+
for (const [i, item] of navValue.entries()) {
|
|
1330
1418
|
const itemPath = ["navigation", navKey, i] as Path;
|
|
1331
1419
|
if (typeof item === "string") {
|
|
1332
1420
|
const normalizedPagePath = normalizeDocsPagePath(item, itemPath);
|
|
1333
1421
|
navValue[i] = normalizedPagePath;
|
|
1334
1422
|
validateFileExistence(normalizedPagePath, itemPath);
|
|
1335
1423
|
} else {
|
|
1336
|
-
validateNavigationNode(item, itemPath);
|
|
1424
|
+
await validateNavigationNode(item, itemPath);
|
|
1337
1425
|
}
|
|
1338
|
-
}
|
|
1426
|
+
}
|
|
1339
1427
|
}
|
|
1340
1428
|
}
|
|
1341
1429
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Icon } from "astro-icon/components";
|
|
3
|
+
import Layout from "../layouts/Layout.astro";
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<Layout
|
|
7
|
+
pageTitle="Page not found"
|
|
8
|
+
pageDescription="The page you requested could not be found."
|
|
9
|
+
>
|
|
10
|
+
<section
|
|
11
|
+
class="mx-auto mt-8 h-full my-auto flex max-w-xl flex-col items-center gap-5 rounded-2xl bg-background px-6 py-10 text-center"
|
|
12
|
+
>
|
|
13
|
+
<p
|
|
14
|
+
class="font-mono text-sm font-medium tracking-[0.22em] text-neutral-500 uppercase dark:text-neutral-400 border px-1 py-px pr-0.5 rounded"
|
|
15
|
+
>
|
|
16
|
+
404
|
|
17
|
+
</p>
|
|
18
|
+
<h1
|
|
19
|
+
class="text-3xl font-semibold tracking-tight text-neutral-900 dark:text-neutral-50"
|
|
20
|
+
>
|
|
21
|
+
Page not found
|
|
22
|
+
</h1>
|
|
23
|
+
<p class="max-w-md text-sm text-neutral-600 dark:text-neutral-300">
|
|
24
|
+
The page may have moved, or the URL may be incorrect.
|
|
25
|
+
</p>
|
|
26
|
+
<div class="flex flex-wrap items-center justify-center gap-3">
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
class="inline-flex items-center justify-center gap-1.5 rounded-lg [corner-shape:superellipse(1.2)] border shadow-xs px-4 py-2 text-sm font-medium text-neutral-700/85 hover:text-neutral-700 cursor-pointer"
|
|
30
|
+
onclick="history.back()"
|
|
31
|
+
>
|
|
32
|
+
<Icon name="lucide:arrow-big-left-dash" class="size-4" />
|
|
33
|
+
Go back
|
|
34
|
+
</button>
|
|
35
|
+
<a
|
|
36
|
+
href="/"
|
|
37
|
+
class="inline-flex items-center justify-center gap-2 rounded-lg [corner-shape:superellipse(1.2)] border border-border px-4 py-2 text-sm font-[350] dark:font-[450] text-white bg-linear-to-b from-neutral-900/85 to-neutral-900 dark:from-neutral-100 dark:to-neutral-200 shadow-sm"
|
|
38
|
+
>
|
|
39
|
+
<Icon name="lucide:house" class="size-4" />
|
|
40
|
+
Take me home
|
|
41
|
+
</a>
|
|
42
|
+
</div>
|
|
43
|
+
</section>
|
|
44
|
+
</Layout>
|