radiant-docs 0.1.61 → 0.1.63

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 (39) hide show
  1. package/package.json +1 -1
  2. package/template/astro.config.mjs +38 -27
  3. package/template/package-lock.json +2858 -1140
  4. package/template/package.json +18 -13
  5. package/template/scripts/generate-proxy-allowed-origins.mjs +10 -179
  6. package/template/scripts/publish-shiki-platform-assets.mjs +1177 -0
  7. package/template/src/components/Header.astro +6 -1
  8. package/template/src/components/NavigationTabList.astro +65 -0
  9. package/template/src/components/NavigationTabs.astro +109 -0
  10. package/template/src/components/OpenApiPage.astro +17 -1
  11. package/template/src/components/Sidebar.astro +2 -2
  12. package/template/src/components/SidebarDropdown.astro +105 -44
  13. package/template/src/components/SidebarMenu.astro +3 -0
  14. package/template/src/components/SidebarSegmented.astro +87 -52
  15. package/template/src/components/SidebarTabs.astro +86 -0
  16. package/template/src/components/chat/AssistantDocsWidget.tsx +127 -2
  17. package/template/src/components/chat/AssistantEmbedPanel.tsx +401 -283
  18. package/template/src/components/endpoint/PlaygroundForm.astro +69 -55
  19. package/template/src/components/endpoint/ResponseDisplay.astro +2 -2
  20. package/template/src/components/user/Accordion.astro +1 -1
  21. package/template/src/components/user/Callout.astro +2 -2
  22. package/template/src/components/user/CodeBlock.astro +58 -7
  23. package/template/src/components/user/CodeGroup.astro +52 -1
  24. package/template/src/components/user/Column.astro +1 -1
  25. package/template/src/components/user/Step.astro +1 -1
  26. package/template/src/components/user/Tabs.astro +1 -1
  27. package/template/src/generated/shiki-platform-assets.json +24 -0
  28. package/template/src/layouts/Layout.astro +111 -8
  29. package/template/src/lib/assistant-panel-config.ts +4 -0
  30. package/template/src/lib/assistant-shiki-client.ts +522 -0
  31. package/template/src/lib/client-shiki-config.ts +60 -0
  32. package/template/src/lib/dev-playground-proxy.mjs +597 -0
  33. package/template/src/lib/mdx/remark-resolve-internal-links.ts +334 -17
  34. package/template/src/lib/proxy-allowed-origins.mjs +189 -0
  35. package/template/src/lib/routes.ts +66 -24
  36. package/template/src/styles/global.css +16 -4
  37. package/template/src/components/ui/demo/CodeDemo.astro +0 -15
  38. package/template/src/components/ui/demo/Demo.astro +0 -3
  39. package/template/src/components/ui/demo/UiDisplay.astro +0 -13
@@ -9,10 +9,12 @@ import {
9
9
  resolveDocsHref,
10
10
  type DocsConfig,
11
11
  type NavGroup,
12
+ type NavMenu,
12
13
  type NavMenuItem,
13
14
  type NavOpenApi,
14
15
  type NavOpenApiPage,
15
16
  type NavPage,
17
+ type NavTabItem,
16
18
  } from "../validation";
17
19
  import { prependBasePath, withBasePath } from "../base-path";
18
20
  import {
@@ -26,6 +28,11 @@ import {
26
28
  } from "../utils";
27
29
 
28
30
  type MdxNavItem = string | NavPage | NavGroup | NavOpenApiPage;
31
+ type NavigationContentContainer = {
32
+ pages?: MdxNavItem[];
33
+ menu?: NavMenu;
34
+ openapi?: string | NavOpenApi;
35
+ };
29
36
 
30
37
  type AuxiliaryPageRef = {
31
38
  filePath: string;
@@ -36,9 +43,24 @@ type DocsConfigWithAuxiliaryRefs = DocsConfig & {
36
43
  auxiliaryPageRefs?: AuxiliaryPageRef[];
37
44
  };
38
45
 
46
+ type OpenApiEndpointLinkRef = {
47
+ source?: string;
48
+ method: string;
49
+ path: string;
50
+ endpoint: string;
51
+ };
52
+
53
+ type OpenApiEndpointRouteTarget = OpenApiEndpointLinkRef & {
54
+ source: string;
55
+ targetKey: string;
56
+ };
57
+
39
58
  type ResolvedRouteIndex = {
40
59
  canonicalHrefByFilePath: Map<string, string>;
41
60
  allHrefsByFilePath: Map<string, string[]>;
61
+ canonicalHrefByOpenApiEndpointKey: Map<string, string>;
62
+ allHrefsByOpenApiEndpointKey: Map<string, string[]>;
63
+ openApiEndpointTargetsByEndpoint: Map<string, OpenApiEndpointRouteTarget[]>;
42
64
  validRoutePaths: Set<string>;
43
65
  allDocsFilePaths: Set<string>;
44
66
  routeSlugs: ReturnType<typeof createRouteSlugRegistry>;
@@ -76,10 +98,24 @@ const HTTP_METHODS = [
76
98
  "options",
77
99
  "trace",
78
100
  ];
101
+ const HTTP_METHOD_SET = new Set<string>(HTTP_METHODS);
79
102
 
80
103
  let cachedRouteIndex: ResolvedRouteIndex | null = null;
81
104
  let cachedConfig: DocsConfig | null = null;
82
105
 
106
+ function isUrl(value: string): boolean {
107
+ try {
108
+ const url = new URL(value);
109
+ return url.protocol === "http:" || url.protocol === "https:";
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ function containsPathTraversal(value: string): boolean {
116
+ return value.replace(/\\/g, "/").split("/").includes("..");
117
+ }
118
+
83
119
  function normalizeDocsFilePath(value: string): string {
84
120
  let normalized = value.replace(/\\/g, "/").trim();
85
121
  if (!normalized) return "";
@@ -94,6 +130,102 @@ function normalizeDocsFilePath(value: string): string {
94
130
  return posixNormalized === "." ? "" : posixNormalized;
95
131
  }
96
132
 
133
+ function normalizeOpenApiLinkSource(source: string): string {
134
+ const trimmedSource = source.trim();
135
+ if (isUrl(trimmedSource)) {
136
+ return trimmedSource;
137
+ }
138
+
139
+ let normalizedSource = trimmedSource.replace(/\\/g, "/").trim();
140
+ normalizedSource = normalizedSource.replace(/^\/+/, "").replace(/\/+$/, "");
141
+ normalizedSource = normalizedSource.replace(/^src\/content\/docs\//, "");
142
+
143
+ const posixNormalized = path.posix
144
+ .normalize(`/${normalizedSource}`)
145
+ .replace(/^\/+/, "");
146
+
147
+ return posixNormalized === "." ? "" : posixNormalized;
148
+ }
149
+
150
+ function formatOpenApiEndpoint(
151
+ method: string,
152
+ endpointPath: string,
153
+ ): string {
154
+ return `${method.toUpperCase()} ${endpointPath.toLowerCase()}`;
155
+ }
156
+
157
+ function formatOpenApiEndpointTargetKey(
158
+ source: string,
159
+ method: string,
160
+ endpointPath: string,
161
+ ): string {
162
+ return `${normalizeOpenApiLinkSource(source)}\0${formatOpenApiEndpoint(
163
+ method,
164
+ endpointPath,
165
+ )}`;
166
+ }
167
+
168
+ function parseOpenApiEndpointLinkEndpoint(
169
+ endpointStr: string,
170
+ ): Pick<OpenApiEndpointLinkRef, "method" | "path" | "endpoint"> | null {
171
+ const trimmed = endpointStr.trim();
172
+ const parts = trimmed.split(/\s+/);
173
+
174
+ if (parts.length !== 2) {
175
+ return null;
176
+ }
177
+
178
+ const method = parts[0].toUpperCase();
179
+ const endpointPath = parts[1];
180
+ if (
181
+ !HTTP_METHOD_SET.has(method.toLowerCase()) ||
182
+ !endpointPath.startsWith("/")
183
+ ) {
184
+ return null;
185
+ }
186
+
187
+ const normalizedPath = endpointPath.toLowerCase();
188
+ return {
189
+ method,
190
+ path: normalizedPath,
191
+ endpoint: formatOpenApiEndpoint(method, normalizedPath),
192
+ };
193
+ }
194
+
195
+ function parseOpenApiEndpointLinkHref(
196
+ href: string,
197
+ ): OpenApiEndpointLinkRef | null {
198
+ const trimmedHref = href.trim();
199
+ if (!trimmedHref) return null;
200
+
201
+ const hashIndex = trimmedHref.indexOf("#");
202
+ if (hashIndex > 0) {
203
+ const rawSource = trimmedHref.slice(0, hashIndex).trim();
204
+ if (!isUrl(rawSource) && containsPathTraversal(rawSource)) {
205
+ return null;
206
+ }
207
+
208
+ const source = normalizeOpenApiLinkSource(rawSource);
209
+ if (!source) {
210
+ return null;
211
+ }
212
+
213
+ const parsedEndpoint = parseOpenApiEndpointLinkEndpoint(
214
+ trimmedHref.slice(hashIndex + 1),
215
+ );
216
+ if (!parsedEndpoint) {
217
+ return null;
218
+ }
219
+
220
+ return {
221
+ source,
222
+ ...parsedEndpoint,
223
+ };
224
+ }
225
+
226
+ return parseOpenApiEndpointLinkEndpoint(trimmedHref);
227
+ }
228
+
97
229
  function normalizeRoutePath(value: string): string {
98
230
  const trimmed = value.trim();
99
231
  if (!trimmed || trimmed === "/") {
@@ -208,6 +340,38 @@ function addMdxRoute(args: {
208
340
  args.index.allHrefsByFilePath.set(normalizedFilePath, aliases);
209
341
  }
210
342
 
343
+ function createOpenApiEndpointRouteTarget(args: {
344
+ source: string;
345
+ method: string;
346
+ endpointPath: string;
347
+ }): OpenApiEndpointRouteTarget {
348
+ const source = normalizeOpenApiLinkSource(args.source);
349
+ const endpoint = formatOpenApiEndpoint(args.method, args.endpointPath);
350
+
351
+ return {
352
+ source,
353
+ method: args.method.toUpperCase(),
354
+ path: args.endpointPath.toLowerCase(),
355
+ endpoint,
356
+ targetKey: formatOpenApiEndpointTargetKey(
357
+ source,
358
+ args.method,
359
+ args.endpointPath,
360
+ ),
361
+ };
362
+ }
363
+
364
+ function addOpenApiEndpointTargetToMap(
365
+ map: Map<string, OpenApiEndpointRouteTarget[]>,
366
+ target: OpenApiEndpointRouteTarget,
367
+ ): void {
368
+ const targets = map.get(target.endpoint) ?? [];
369
+ if (!targets.some((candidate) => candidate.targetKey === target.targetKey)) {
370
+ targets.push(target);
371
+ }
372
+ map.set(target.endpoint, targets);
373
+ }
374
+
211
375
  function addOpenApiEndpointRoute(args: {
212
376
  index: ResolvedRouteIndex;
213
377
  parentSlug: string;
@@ -232,10 +396,30 @@ function addOpenApiEndpointRoute(args: {
232
396
  }),
233
397
  identity: `openapi:${args.source}:${args.method.toUpperCase()} ${args.endpointPath.toLowerCase()}`,
234
398
  }).slug;
235
- addValidRoutePath(
399
+ const href = addValidRoutePath(
236
400
  args.index,
237
401
  prependBasePath(`/${registeredSlug}`),
238
402
  );
403
+ const target = createOpenApiEndpointRouteTarget({
404
+ source: args.source,
405
+ method: args.method,
406
+ endpointPath: args.endpointPath,
407
+ });
408
+
409
+ if (!args.index.canonicalHrefByOpenApiEndpointKey.has(target.targetKey)) {
410
+ args.index.canonicalHrefByOpenApiEndpointKey.set(target.targetKey, href);
411
+ }
412
+
413
+ const aliases =
414
+ args.index.allHrefsByOpenApiEndpointKey.get(target.targetKey) ?? [];
415
+ if (!aliases.includes(href)) {
416
+ aliases.push(href);
417
+ }
418
+ args.index.allHrefsByOpenApiEndpointKey.set(target.targetKey, aliases);
419
+ addOpenApiEndpointTargetToMap(
420
+ args.index.openApiEndpointTargetsByEndpoint,
421
+ target,
422
+ );
239
423
  }
240
424
 
241
425
  function addAuxiliaryPageRef(
@@ -414,27 +598,69 @@ async function processMenuItems(args: {
414
598
  index: ResolvedRouteIndex;
415
599
  homePath: string | null;
416
600
  items: NavMenuItem[];
601
+ parentSlug?: string;
417
602
  }): Promise<void> {
418
603
  for (const menuItem of args.items) {
419
604
  const menuSlug = slugify(menuItem.label);
420
- const parentSlug = menuSlug;
605
+ const parentSlug = args.parentSlug
606
+ ? `${args.parentSlug}/${menuSlug}`
607
+ : menuSlug;
608
+
609
+ await processNavigationContent({
610
+ index: args.index,
611
+ parentSlug,
612
+ homePath: args.homePath,
613
+ item: menuItem,
614
+ });
615
+ }
616
+ }
421
617
 
422
- if (Array.isArray(menuItem.submenu.pages)) {
423
- await processPages({
424
- index: args.index,
425
- parentSlug,
426
- homePath: args.homePath,
427
- items: menuItem.submenu.pages as MdxNavItem[],
428
- });
429
- }
618
+ async function processTabItems(args: {
619
+ index: ResolvedRouteIndex;
620
+ homePath: string | null;
621
+ items: NavTabItem[];
622
+ }): Promise<void> {
623
+ for (const tabItem of args.items) {
624
+ const tabSlug = slugify(tabItem.slug || tabItem.label);
625
+ await processNavigationContent({
626
+ index: args.index,
627
+ parentSlug: tabSlug,
628
+ homePath: args.homePath,
629
+ item: tabItem,
630
+ });
631
+ }
632
+ }
430
633
 
431
- if (menuItem.submenu.openapi) {
432
- await processOpenApiFile({
433
- index: args.index,
434
- parentSlug,
435
- openApiPathOrConfig: menuItem.submenu.openapi,
436
- });
437
- }
634
+ async function processNavigationContent(args: {
635
+ index: ResolvedRouteIndex;
636
+ parentSlug: string;
637
+ homePath: string | null;
638
+ item: NavigationContentContainer;
639
+ }): Promise<void> {
640
+ if (Array.isArray(args.item.pages)) {
641
+ await processPages({
642
+ index: args.index,
643
+ parentSlug: args.parentSlug,
644
+ homePath: args.homePath,
645
+ items: args.item.pages as MdxNavItem[],
646
+ });
647
+ }
648
+
649
+ if (args.item.menu?.items) {
650
+ await processMenuItems({
651
+ index: args.index,
652
+ parentSlug: args.parentSlug,
653
+ homePath: args.homePath,
654
+ items: args.item.menu.items,
655
+ });
656
+ }
657
+
658
+ if (args.item.openapi) {
659
+ await processOpenApiFile({
660
+ index: args.index,
661
+ parentSlug: args.parentSlug,
662
+ openApiPathOrConfig: args.item.openapi,
663
+ });
438
664
  }
439
665
  }
440
666
 
@@ -444,6 +670,12 @@ async function buildRouteIndex(
444
670
  const index: ResolvedRouteIndex = {
445
671
  canonicalHrefByFilePath: new Map<string, string>(),
446
672
  allHrefsByFilePath: new Map<string, string[]>(),
673
+ canonicalHrefByOpenApiEndpointKey: new Map<string, string>(),
674
+ allHrefsByOpenApiEndpointKey: new Map<string, string[]>(),
675
+ openApiEndpointTargetsByEndpoint: new Map<
676
+ string,
677
+ OpenApiEndpointRouteTarget[]
678
+ >(),
447
679
  validRoutePaths: new Set<string>(),
448
680
  allDocsFilePaths: new Set<string>(),
449
681
  routeSlugs: createRouteSlugRegistry(),
@@ -473,6 +705,14 @@ async function buildRouteIndex(
473
705
  });
474
706
  }
475
707
 
708
+ if (config.navigation.tabs?.items) {
709
+ await processTabItems({
710
+ index,
711
+ homePath,
712
+ items: config.navigation.tabs.items,
713
+ });
714
+ }
715
+
476
716
  const rootOpenApi = (config.navigation as any).openapi;
477
717
  if (
478
718
  typeof rootOpenApi === "string" ||
@@ -527,12 +767,89 @@ function buildInternalLinkRewriteError(args: {
527
767
  );
528
768
  }
529
769
 
770
+ function formatOpenApiEndpointSourceList(
771
+ targets: OpenApiEndpointRouteTarget[],
772
+ ): string {
773
+ const sources = [...new Set(targets.map((target) => target.source))];
774
+ return sources.map((source) => `"${source}"`).join(", ");
775
+ }
776
+
777
+ function resolveOpenApiEndpointLinkToCanonicalHref(args: {
778
+ rawHref: string;
779
+ endpointLink: OpenApiEndpointLinkRef;
780
+ currentDocFilePath: string | null;
781
+ routeIndex: ResolvedRouteIndex;
782
+ warn: (message: string) => void;
783
+ }): string {
784
+ const target =
785
+ args.endpointLink.source !== undefined
786
+ ? {
787
+ source: args.endpointLink.source,
788
+ targetKey: formatOpenApiEndpointTargetKey(
789
+ args.endpointLink.source,
790
+ args.endpointLink.method,
791
+ args.endpointLink.path,
792
+ ),
793
+ }
794
+ : (() => {
795
+ const targets =
796
+ args.routeIndex.openApiEndpointTargetsByEndpoint.get(
797
+ args.endpointLink.endpoint,
798
+ ) ?? [];
799
+ if (targets.length === 1) {
800
+ return targets[0];
801
+ }
802
+
803
+ throw buildInternalLinkRewriteError({
804
+ rawHref: args.rawHref,
805
+ currentDocFilePath: args.currentDocFilePath,
806
+ detail:
807
+ targets.length > 1
808
+ ? `The endpoint "${args.endpointLink.endpoint}" is ambiguous across OpenAPI sources (${formatOpenApiEndpointSourceList(
809
+ targets,
810
+ )}).`
811
+ : `No generated OpenAPI endpoint route matched "${args.endpointLink.endpoint}".`,
812
+ });
813
+ })();
814
+
815
+ const canonicalHref =
816
+ args.routeIndex.canonicalHrefByOpenApiEndpointKey.get(target.targetKey);
817
+ if (!canonicalHref) {
818
+ throw buildInternalLinkRewriteError({
819
+ rawHref: args.rawHref,
820
+ currentDocFilePath: args.currentDocFilePath,
821
+ detail: `The OpenAPI endpoint "${args.endpointLink.endpoint}" from "${target.source}" did not resolve to a canonical route.`,
822
+ });
823
+ }
824
+
825
+ const aliases =
826
+ args.routeIndex.allHrefsByOpenApiEndpointKey.get(target.targetKey) ?? [];
827
+ if (aliases.length > 1) {
828
+ args.warn(
829
+ `[INTERNAL_LINK_WARNING] "${args.rawHref}" matched OpenAPI endpoint "${args.endpointLink.endpoint}" in "${target.source}", which has multiple URLs (${aliases.join(", ")}). Rewriting to canonical URL "${canonicalHref}".`,
830
+ );
831
+ }
832
+
833
+ return withBasePath(canonicalHref);
834
+ }
835
+
530
836
  function resolveLinkToCanonicalHref(args: {
531
837
  rawHref: string;
532
838
  currentDocFilePath: string | null;
533
839
  routeIndex: ResolvedRouteIndex;
534
840
  warn: (message: string) => void;
535
841
  }): string | null {
842
+ const endpointLink = parseOpenApiEndpointLinkHref(args.rawHref);
843
+ if (endpointLink) {
844
+ return resolveOpenApiEndpointLinkToCanonicalHref({
845
+ rawHref: args.rawHref,
846
+ endpointLink,
847
+ currentDocFilePath: args.currentDocFilePath,
848
+ routeIndex: args.routeIndex,
849
+ warn: args.warn,
850
+ });
851
+ }
852
+
536
853
  const resolvedHref = resolveDocsHref(args.rawHref);
537
854
  if (resolvedHref.kind === "ignored" || resolvedHref.kind === "local-asset") {
538
855
  return null;
@@ -0,0 +1,189 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import YAML from "yaml";
4
+
5
+ export function isRecord(value) {
6
+ return typeof value === "object" && value !== null;
7
+ }
8
+
9
+ function isHttpUrl(value) {
10
+ return /^https?:\/\//i.test(String(value).trim());
11
+ }
12
+
13
+ function collectOpenApiSourcesFromNavigation(navigationNode, target) {
14
+ if (!isRecord(navigationNode)) {
15
+ return;
16
+ }
17
+
18
+ const openApiConfig = navigationNode.openapi;
19
+ if (typeof openApiConfig === "string") {
20
+ const trimmed = openApiConfig.trim();
21
+ if (trimmed) {
22
+ target.add(trimmed);
23
+ }
24
+ } else if (
25
+ isRecord(openApiConfig) &&
26
+ typeof openApiConfig.source === "string"
27
+ ) {
28
+ const trimmed = openApiConfig.source.trim();
29
+ if (trimmed) {
30
+ target.add(trimmed);
31
+ }
32
+ }
33
+
34
+ const menu = navigationNode.menu;
35
+ if (isRecord(menu) && Array.isArray(menu.items)) {
36
+ for (const menuItem of menu.items) {
37
+ if (!isRecord(menuItem)) {
38
+ continue;
39
+ }
40
+ collectOpenApiSourcesFromNavigation(menuItem, target);
41
+ }
42
+ }
43
+
44
+ const tabs = navigationNode.tabs;
45
+ if (isRecord(tabs) && Array.isArray(tabs.items)) {
46
+ for (const tabItem of tabs.items) {
47
+ if (!isRecord(tabItem)) {
48
+ continue;
49
+ }
50
+ collectOpenApiSourcesFromNavigation(tabItem, target);
51
+ }
52
+ }
53
+ }
54
+
55
+ function resolveServerTemplateUrl(rawUrl, rawVariables) {
56
+ let unresolved = false;
57
+ const variables = isRecord(rawVariables) ? rawVariables : null;
58
+
59
+ const resolved = rawUrl.replace(/\{([^}]+)\}/g, (_match, tokenName) => {
60
+ if (!variables) {
61
+ unresolved = true;
62
+ return "";
63
+ }
64
+
65
+ const variableConfig = variables[tokenName];
66
+ if (
67
+ !isRecord(variableConfig) ||
68
+ typeof variableConfig.default !== "string"
69
+ ) {
70
+ unresolved = true;
71
+ return "";
72
+ }
73
+
74
+ const defaultValue = variableConfig.default.trim();
75
+ if (!defaultValue) {
76
+ unresolved = true;
77
+ return "";
78
+ }
79
+
80
+ return defaultValue;
81
+ });
82
+
83
+ if (unresolved) {
84
+ return null;
85
+ }
86
+
87
+ return resolved;
88
+ }
89
+
90
+ export function normalizeAllowedOrigin(rawUrl) {
91
+ let parsed;
92
+ try {
93
+ parsed = new URL(rawUrl);
94
+ } catch {
95
+ return null;
96
+ }
97
+
98
+ if (parsed.protocol !== "https:") {
99
+ return null;
100
+ }
101
+
102
+ if (!parsed.hostname || parsed.username || parsed.password) {
103
+ return null;
104
+ }
105
+
106
+ return parsed.origin.toLowerCase();
107
+ }
108
+
109
+ async function loadOpenApiSpec(source, { docsDir, fetchImpl }) {
110
+ let fileContent;
111
+
112
+ if (isHttpUrl(source)) {
113
+ const response = await fetchImpl(source);
114
+ if (!response.ok) {
115
+ throw new Error(
116
+ `Failed to fetch OpenAPI spec (${response.status} ${response.statusText})`,
117
+ );
118
+ }
119
+ fileContent = await response.text();
120
+ } else {
121
+ const absolutePath = path.join(docsDir, source);
122
+ fileContent = await fs.readFile(absolutePath, "utf8");
123
+ }
124
+
125
+ const trimmed = fileContent.trim();
126
+ if (trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html")) {
127
+ throw new Error("OpenAPI source returned HTML instead of JSON or YAML");
128
+ }
129
+
130
+ try {
131
+ return JSON.parse(fileContent);
132
+ } catch {
133
+ return YAML.parse(fileContent);
134
+ }
135
+ }
136
+
137
+ export async function buildAllowedOrigins({
138
+ cwd = process.cwd(),
139
+ docsDir = path.join(cwd, "src/content/docs"),
140
+ docsConfigPath = path.join(docsDir, "docs.json"),
141
+ fetchImpl = fetch,
142
+ onSourceError = undefined,
143
+ } = {}) {
144
+ const docsConfigRaw = await fs.readFile(docsConfigPath, "utf8");
145
+ const docsConfig = JSON.parse(docsConfigRaw);
146
+
147
+ if (!isRecord(docsConfig) || !isRecord(docsConfig.navigation)) {
148
+ return [];
149
+ }
150
+
151
+ const sources = new Set();
152
+ collectOpenApiSourcesFromNavigation(docsConfig.navigation, sources);
153
+ if (sources.size === 0) {
154
+ return [];
155
+ }
156
+
157
+ const allowedOrigins = new Set();
158
+
159
+ for (const source of sources) {
160
+ try {
161
+ const spec = await loadOpenApiSpec(source, { docsDir, fetchImpl });
162
+ const servers =
163
+ isRecord(spec) && Array.isArray(spec.servers) ? spec.servers : [];
164
+
165
+ for (const server of servers) {
166
+ if (!isRecord(server) || typeof server.url !== "string") {
167
+ continue;
168
+ }
169
+
170
+ const resolvedUrl = resolveServerTemplateUrl(
171
+ server.url,
172
+ server.variables,
173
+ );
174
+ if (!resolvedUrl) {
175
+ continue;
176
+ }
177
+
178
+ const normalizedOrigin = normalizeAllowedOrigin(resolvedUrl);
179
+ if (normalizedOrigin) {
180
+ allowedOrigins.add(normalizedOrigin);
181
+ }
182
+ }
183
+ } catch (error) {
184
+ onSourceError?.(source, error);
185
+ }
186
+ }
187
+
188
+ return Array.from(allowedOrigins).sort();
189
+ }