radiant-docs 0.1.33 → 0.1.37

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.
@@ -33,10 +33,12 @@ const {
33
33
  bodyDefaultKind,
34
34
  } = Astro.props;
35
35
  const config = await getConfig();
36
- const proxyEnabled = config.playground?.proxy !== false;
37
- const proxyUrl =
38
- import.meta.env.PUBLIC_PROXY_URL ||
39
- "https://docs-proxy.stefanjoseph-dev.workers.dev";
36
+ const configuredProxyUrl =
37
+ typeof import.meta.env.PUBLIC_PROXY_URL === "string"
38
+ ? import.meta.env.PUBLIC_PROXY_URL.trim()
39
+ : "";
40
+ const proxyUrl = configuredProxyUrl || "/_platform/proxy";
41
+ const proxyEnabled = config.playground?.proxy !== false && proxyUrl.length > 0;
40
42
  const formattedBodyDescription = bodyDescription
41
43
  ? await renderMarkdown(bodyDescription)
42
44
  : null;
@@ -538,20 +540,22 @@ const sectionVariantFieldNames = Object.fromEntries(
538
540
  <button
539
541
  @click="sendRequest($event)"
540
542
  :disabled="loading"
541
- class="m-px flex items-center gap-1.5 px-4 py-[5px] text-sm font-medium rounded-lg bg-neutral-900 text-white/95 hover:text-white shadow-[inset_0_1px_0_rgb(255,255,255,0.3),0_0_0_1px_var(--color-neutral-800)] duration-200 whitespace-nowrap cursor-pointer relative before:absolute before:inset-0 before:shadow-sm before:rounded-lg before:bg-transparent disabled:opacity-70"
543
+ class="m-px font-[350] relative flex h-8 items-center gap-2 rounded-lg [corner-shape:superellipse(1.2)] bg-linear-to-b from-neutral-900/85 to-neutral-900 px-3 text-[13px] text-white shadow-sm dark:bg-white dark:text-neutral-900 transition-all duration-200 whitespace-nowrap cursor-pointer disabled:opacity-70 disabled:cursor-not-allowed"
542
544
  >
543
- <span
544
- class="flex items-center gap-2"
545
- x-bind:class="loading ? 'opacity-0':''"
546
- >
545
+ <span class="flex items-center gap-2">
546
+ <Icon
547
+ x-show="!loading"
548
+ class="size-3.5 -ml-px"
549
+ name="lucide:square-arrow-up-right"
550
+ />
551
+ <Icon
552
+ x-show="loading"
553
+ x-cloak
554
+ class="size-3.5 animate-spin -ml-px"
555
+ name="lucide:loader"
556
+ />
547
557
  Send <span class="hidden xs:inline">Request</span>
548
- <Icon class="size-4" name="lucide:square-arrow-up-right" />
549
558
  </span>
550
- <Icon
551
- x-show="loading"
552
- class="size-4 absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 animate-spin **:stroke-3"
553
- name="lucide:loader"
554
- />
555
559
  </button>
556
560
  </PlaygroundBar>
557
561
 
@@ -578,7 +582,7 @@ const sectionVariantFieldNames = Object.fromEntries(
578
582
  }
579
583
 
580
584
  return (
581
- <div class="border border-neutral-200 shadow-xs rounded-xl p-4">
585
+ <div class="border border-neutral-200 shadow-xs rounded-xl p-4 pb-0 [&_[role='region']]:border-b-0">
582
586
  <Accordion title={headers[key]} defaultOpen titleSize="xl">
583
587
  {key === "body" && formattedBodyDescription && (
584
588
  <div
@@ -1,28 +1,28 @@
1
1
  ---
2
- import { slugify } from "../../lib/utils";
2
+ import Tag from "../ui/Tag.astro";
3
+ import { buildOpenApiEndpointHref } from "../../lib/utils";
3
4
  import { methodColors } from "../../lib/utils";
4
5
 
5
6
  interface Props {
6
7
  method: string;
7
8
  path: string;
8
9
  summary?: string;
10
+ title?: string;
11
+ tag?: string;
9
12
  parentSlug?: string;
10
13
  }
11
14
 
12
- const { method, path: pathStr, summary, parentSlug = "" } = Astro.props;
15
+ const { method, path: pathStr, summary, title, tag, parentSlug = "" } = Astro.props;
13
16
 
14
- // Generate slug from path and method (matching routes.ts logic)
15
- const pathSlug = pathStr
16
- .replace(/^\//, "") // Remove leading slash
17
- .replace(/\//g, "-") // Replace slashes with hyphens
18
- .replace(/\{[^}]+\}/g, "param"); // Replace path params like {id} with "param"
19
-
20
- const methodSlug = slugify(method);
21
- const endpointSlug = pathSlug ? `${pathSlug}-${methodSlug}` : methodSlug;
22
- const href = parentSlug ? `/${parentSlug}/${endpointSlug}` : `/${endpointSlug}`;
17
+ const normalizedMethod = method.toLowerCase();
18
+ const href = buildOpenApiEndpointHref({
19
+ path: pathStr,
20
+ method: normalizedMethod,
21
+ groupSlug: parentSlug,
22
+ });
23
23
 
24
24
  // Use summary for title, fallback to method + path
25
- const text = summary || pathStr;
25
+ const text = title || summary || `${normalizedMethod.toUpperCase()} ${pathStr}`;
26
26
 
27
27
  // Normalize paths for comparison (remove trailing slashes)
28
28
  const currentPath = Astro.url.pathname.replace(/\/$/, "");
@@ -42,10 +42,13 @@ const isActive = currentPath === targetPath;
42
42
  <span
43
43
  class:list={[
44
44
  "px-1 py-px mr-1.5 border rounded-md text-[10px] font-semibold uppercase",
45
- methodColors[method],
45
+ methodColors[normalizedMethod] ?? methodColors.get,
46
46
  ]}
47
47
  >
48
- {method.toLowerCase() !== "delete" ? method : "del"}
48
+ {normalizedMethod !== "delete" ? normalizedMethod : "del"}
49
49
  </span>
50
- {text}
50
+ <div class="flex items-center gap-2 min-w-0">
51
+ <span>{text}</span>
52
+ {tag && <Tag>{tag}</Tag>}
53
+ </div>
51
54
  </a>
@@ -0,0 +1,56 @@
1
+ ---
2
+ import type { NavOpenApiPage } from "../../lib/validation";
3
+ import { loadOpenApiSpec } from "../../lib/validation";
4
+ import { parseOpenApiEndpoint } from "../../lib/utils";
5
+ import SidebarEndpointLink from "./SidebarEndpointLink.astro";
6
+
7
+ interface Props {
8
+ item: NavOpenApiPage;
9
+ parentSlug?: string;
10
+ }
11
+
12
+ const { item, parentSlug = "" } = Astro.props;
13
+
14
+ const parsedEndpoint = parseOpenApiEndpoint(item.openapi.endpoint);
15
+ if (!parsedEndpoint) {
16
+ throw new Error(
17
+ `Invalid OpenAPI endpoint format "${item.openapi.endpoint}". Expected "METHOD /path".`,
18
+ );
19
+ }
20
+
21
+ const openApiDoc = await loadOpenApiSpec(item.openapi.source);
22
+ const openApiPaths = openApiDoc?.paths ?? {};
23
+ const endpointMethod = parsedEndpoint.method.toLowerCase();
24
+
25
+ let matchedPath: string | null = null;
26
+ let endpointSummary: string | undefined;
27
+
28
+ for (const [pathStr, pathItem] of Object.entries(openApiPaths)) {
29
+ if (!pathItem || typeof pathItem !== "object") continue;
30
+ if (pathStr.toLowerCase() !== parsedEndpoint.path) continue;
31
+
32
+ const operation = (pathItem as any)[endpointMethod];
33
+ if (!operation) continue;
34
+
35
+ matchedPath = pathStr;
36
+ if (typeof operation.summary === "string") {
37
+ endpointSummary = operation.summary;
38
+ }
39
+ break;
40
+ }
41
+
42
+ if (!matchedPath) {
43
+ throw new Error(
44
+ `OpenAPI endpoint "${item.openapi.endpoint}" was not found in source "${item.openapi.source}".`,
45
+ );
46
+ }
47
+ ---
48
+
49
+ <SidebarEndpointLink
50
+ method={endpointMethod}
51
+ path={matchedPath}
52
+ summary={endpointSummary}
53
+ title={item.title}
54
+ tag={item.tag}
55
+ parentSlug={parentSlug}
56
+ />
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { Icon as AstroIcon } from "astro-icon/components";
3
+ import { resolveStaticAssetUrl } from "../../lib/static-asset-url";
3
4
 
4
5
  interface Props {
5
6
  name: string | undefined | null;
@@ -19,7 +20,7 @@ const isLocal = !isUrl && !isIconify;
19
20
  // Files from src/content/docs are copied to public root by copyContentAssets plugin
20
21
  let src = name;
21
22
  if (isLocal) {
22
- src = name.startsWith("/") ? name : `/${name}`;
23
+ src = resolveStaticAssetUrl(name.startsWith("/") ? name : `/${name}`);
23
24
  }
24
25
  ---
25
26
 
@@ -2,6 +2,7 @@
2
2
  import type { HTMLAttributes } from "astro/types";
3
3
  import { validateProps } from "../../lib/component-error";
4
4
  import { renderMarkdown } from "../../lib/utils";
5
+ import { resolveStaticAssetUrl } from "../../lib/static-asset-url";
5
6
 
6
7
  interface Props extends HTMLAttributes<"img"> {
7
8
  src: string;
@@ -11,6 +12,9 @@ interface Props extends HTMLAttributes<"img"> {
11
12
  const { title, zoom = true, ...attrs } = Astro.props as Props;
12
13
  const zoomEnabled = zoom !== false;
13
14
  const attrsRecord = attrs as Record<string, unknown>;
15
+ if (typeof attrsRecord.src === "string") {
16
+ attrsRecord.src = resolveStaticAssetUrl(attrsRecord.src);
17
+ }
14
18
  const rawStyle = attrsRecord.style;
15
19
  const rawWidth = attrsRecord.width;
16
20
  const zoomAttrs: Record<string, unknown> = { ...attrsRecord };
@@ -12,6 +12,7 @@ import Header from "../components/Header.astro";
12
12
  import Footer from "../components/Footer.astro";
13
13
  import AskAiWidget from "../components/chat/AskAiWidget";
14
14
  import { ClientRouter } from "astro:transitions";
15
+ import { resolveStaticAssetUrl } from "../lib/static-asset-url";
15
16
 
16
17
  interface Props {
17
18
  pageTitle?: string;
@@ -52,7 +53,7 @@ const canonicalUrl = new URL(
52
53
  Astro.site ?? Astro.url,
53
54
  ).toString();
54
55
  const ogImageUrl = new URL(
55
- routePathToOgImagePath(Astro.url.pathname),
56
+ resolveStaticAssetUrl(routePathToOgImagePath(Astro.url.pathname)),
56
57
  Astro.site ?? Astro.url,
57
58
  );
58
59
  const ogImageHref = ogImageUrl.toString();
@@ -144,7 +145,11 @@ if (isDev && hasAskAiDevConfig) {
144
145
  />
145
146
  <meta charset="UTF-8" />
146
147
  <meta name="viewport" content="width=device-width" />
147
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
148
+ <link
149
+ rel="icon"
150
+ type="image/svg+xml"
151
+ href={resolveStaticAssetUrl("/favicon.svg")}
152
+ />
148
153
  <meta name="generator" content={Astro.generator} />
149
154
  <link rel="canonical" href={canonicalUrl} />
150
155
  <meta property="og:url" content={canonicalUrl} />
@@ -211,7 +216,7 @@ if (isDev && hasAskAiDevConfig) {
211
216
 
212
217
  <!-- Main Content -->
213
218
  <div
214
- class="mx-1 mt-1 px-4 sm:px-6 lg:pl-[calc(288px+32px)] pt-16 lg:pr-8 bg-white"
219
+ class="mx-1 mt-1 px-4 sm:px-6 lg:pl-[calc(288px+32px)] pt-16 lg:pr-8 bg-background"
215
220
  data-vaul-scale-chrome
216
221
  >
217
222
  <main
@@ -1,4 +1,5 @@
1
1
  // Pagefind TypeScript types and wrapper
2
+ import { resolveStaticAssetUrl } from "./static-asset-url";
2
3
 
3
4
  export interface PagefindSearchResult {
4
5
  id: string;
@@ -50,7 +51,7 @@ export interface PagefindInstance {
50
51
  let pagefindInstance: PagefindInstance | null = null;
51
52
 
52
53
  function getPagefindScriptUrl(): string {
53
- return "/pagefind/pagefind.js";
54
+ return resolveStaticAssetUrl("/pagefind/pagefind.js");
54
55
  }
55
56
 
56
57
  export async function getPagefind(): Promise<PagefindInstance | null> {
@@ -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
+ }