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.
Files changed (56) hide show
  1. package/package.json +1 -1
  2. package/template/astro.config.mjs +27 -0
  3. package/template/package-lock.json +1027 -513
  4. package/template/package.json +3 -2
  5. package/template/scripts/generate-proxy-allowed-origins.mjs +217 -0
  6. package/template/scripts/generate-robots-txt.mjs +19 -0
  7. package/template/scripts/stamp-image-versions.mjs +63 -11
  8. package/template/src/components/Footer.astro +1 -1
  9. package/template/src/components/Header.astro +9 -9
  10. package/template/src/components/LogoLink.astro +2 -1
  11. package/template/src/components/OpenApiPage.astro +18 -18
  12. package/template/src/components/Search.astro +18 -18
  13. package/template/src/components/Sidebar.astro +4 -2
  14. package/template/src/components/SidebarDropdown.astro +82 -79
  15. package/template/src/components/SidebarGroup.astro +3 -0
  16. package/template/src/components/SidebarMenu.astro +14 -1
  17. package/template/src/components/SidebarSegmented.astro +5 -5
  18. package/template/src/components/SidebarSubgroup.astro +35 -12
  19. package/template/src/components/TableOfContents.astro +24 -15
  20. package/template/src/components/ThemeSwitcher.astro +15 -8
  21. package/template/src/components/chat/AskAiWidget.tsx +10 -5
  22. package/template/src/components/endpoint/PlaygroundBar.astro +3 -3
  23. package/template/src/components/endpoint/PlaygroundButton.astro +3 -3
  24. package/template/src/components/endpoint/PlaygroundField.astro +53 -53
  25. package/template/src/components/endpoint/PlaygroundForm.astro +51 -37
  26. package/template/src/components/endpoint/RequestSnippets.astro +54 -21
  27. package/template/src/components/endpoint/ResponseDisplay.astro +24 -24
  28. package/template/src/components/endpoint/ResponseFieldTree.astro +12 -12
  29. package/template/src/components/endpoint/ResponseFields.astro +19 -19
  30. package/template/src/components/endpoint/ResponseSnippets.astro +66 -29
  31. package/template/src/components/sidebar/SidebarEndpointLink.astro +18 -15
  32. package/template/src/components/sidebar/SidebarOpenApiPageLink.astro +56 -0
  33. package/template/src/components/ui/CodeTabEdge.astro +6 -4
  34. package/template/src/components/ui/Field.astro +7 -7
  35. package/template/src/components/ui/Icon.astro +2 -1
  36. package/template/src/components/ui/demo/Demo.astro +1 -1
  37. package/template/src/components/user/Accordion.astro +3 -3
  38. package/template/src/components/user/Callout.astro +8 -8
  39. package/template/src/components/user/CodeBlock.astro +57 -22
  40. package/template/src/components/user/CodeGroup.astro +14 -10
  41. package/template/src/components/user/ComponentPreviewBlock.astro +38 -12
  42. package/template/src/components/user/Image.astro +6 -2
  43. package/template/src/components/user/Step.astro +4 -4
  44. package/template/src/components/user/Tab.astro +1 -1
  45. package/template/src/components/user/Tabs.astro +15 -20
  46. package/template/src/layouts/Layout.astro +9 -4
  47. package/template/src/lib/code/code-block.ts +150 -15
  48. package/template/src/lib/mdx/remark-resolve-internal-links.ts +639 -0
  49. package/template/src/lib/pagefind.ts +2 -1
  50. package/template/src/lib/routes.ts +134 -58
  51. package/template/src/lib/static-asset-url.ts +62 -0
  52. package/template/src/lib/utils.ts +48 -0
  53. package/template/src/lib/validation.ts +115 -27
  54. package/template/src/pages/404.astro +44 -0
  55. package/template/src/styles/global.css +28 -19
  56. 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 { deriveTitleFromEntryId, slugify } from "./utils";
10
- import path from "node:path";
10
+ import {
11
+ buildOpenApiEndpointSlug,
12
+ deriveTitleFromEntryId,
13
+ parseOpenApiEndpoint,
14
+ slugify,
15
+ } from "./utils";
11
16
  import { getCollection } from "astro:content";
12
17
 
13
- // NOTE: You need to define NavPageItem to include strings, pages, and groups
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: NavPageItem,
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
- function processGroup(
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.forEach((item) => {
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 as NavPageItem, currentPrefix, docs));
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
- const method = parts[0].toUpperCase();
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 = parseEndpointString(entry);
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 = parseEndpointString(entry);
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
- // Generate slug from path and method (guaranteed to be unique)
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.forEach((item) => {
326
+ for (const item of submenu.pages) {
281
327
  if (typeof item === "string" || "page" in item) {
282
- routes.push(processPageItem(item as NavPageItem, currentPrefix, docs));
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.forEach((item) => {
390
+ for (const item of navigation.pages) {
318
391
  if (typeof item === "string" || "page" in item) {
319
- allRoutes.push(processPageItem(item as NavPageItem, "", docs));
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 validateNavigationNode(
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 'group'.",
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.forEach((child: any, i: number) => {
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
- return;
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"])?.forEach(
894
- (item: string | NavPage | NavGroup, i: number) => {
895
- const itemPath = [...currentPath, "submenu", "pages", i];
896
- if (typeof item === "string") {
897
- const normalizedPagePath = normalizeDocsPagePath(item, itemPath);
898
- (submenuValue as (string | NavPage | NavGroup)[])[i] =
899
- normalizedPagePath;
900
- validateFileExistence(normalizedPagePath, itemPath);
901
- } else {
902
- validateNavigationNode(item, itemPath);
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
- navValue.forEach((item: NavigationItem, i: number) => {
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>