radiant-docs 0.1.34 → 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.
@@ -6,10 +6,10 @@
6
6
  "dev": "astro dev",
7
7
  "start": "tsx runner.ts",
8
8
  "prebuild": "rm -rf public/pagefind",
9
- "build": "astro build && node scripts/generate-og-metadata.mjs && node scripts/generate-og-images.mjs && node scripts/stamp-og-image-versions.mjs && node scripts/stamp-image-versions.mjs && pagefind --site dist && node scripts/stamp-pagefind-runtime-version.mjs && node scripts/rewrite-static-asset-host.mjs",
9
+ "build": "astro build && node scripts/generate-og-metadata.mjs && node scripts/generate-og-images.mjs && node scripts/stamp-og-image-versions.mjs && node scripts/stamp-image-versions.mjs && pagefind --site dist && node scripts/stamp-pagefind-runtime-version.mjs && node scripts/generate-proxy-allowed-origins.mjs && node scripts/generate-robots-txt.mjs",
10
10
  "preview": "astro preview",
11
11
  "astro": "astro",
12
- "search:index": "astro build && node scripts/generate-og-metadata.mjs && node scripts/generate-og-images.mjs && node scripts/stamp-og-image-versions.mjs && node scripts/stamp-image-versions.mjs && pagefind --site dist && node scripts/stamp-pagefind-runtime-version.mjs && node scripts/rewrite-static-asset-host.mjs && cp -r dist/pagefind public/"
12
+ "search:index": "astro build && node scripts/generate-og-metadata.mjs && node scripts/generate-og-images.mjs && node scripts/stamp-og-image-versions.mjs && node scripts/stamp-image-versions.mjs && pagefind --site dist && node scripts/stamp-pagefind-runtime-version.mjs && node scripts/generate-proxy-allowed-origins.mjs && node scripts/generate-robots-txt.mjs && cp -r dist/pagefind public/"
13
13
  },
14
14
  "dependencies": {
15
15
  "@alpinejs/collapse": "^3.15.2",
@@ -18,6 +18,7 @@
18
18
  "@astrojs/alpinejs": "^0.4.9",
19
19
  "@astrojs/mdx": "^4.3.12",
20
20
  "@astrojs/preact": "^4.1.3",
21
+ "@astrojs/sitemap": "^3.7.2",
21
22
  "@aws-sdk/client-s3": "^3.964.0",
22
23
  "@fontsource/google-sans-flex": "^5.2.2",
23
24
  "@iconify-json/lucide": "^1.2.79",
@@ -0,0 +1,217 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import YAML from "yaml";
4
+
5
+ const CWD = process.cwd();
6
+ const DOCS_DIR = path.join(CWD, "src/content/docs");
7
+ const DOCS_CONFIG_PATH = path.join(DOCS_DIR, "docs.json");
8
+ const OUTPUT_DIR = path.join(CWD, ".radiant");
9
+ const OUTPUT_FILE = path.join(OUTPUT_DIR, "proxy-allowed-origins.json");
10
+
11
+ function isRecord(value) {
12
+ return typeof value === "object" && value !== null;
13
+ }
14
+
15
+ function isHttpUrl(value) {
16
+ return /^https?:\/\//i.test(String(value).trim());
17
+ }
18
+
19
+ function collectOpenApiSourcesFromNavigation(navigationNode, target) {
20
+ if (!isRecord(navigationNode)) {
21
+ return;
22
+ }
23
+
24
+ const openApiConfig = navigationNode.openapi;
25
+ if (typeof openApiConfig === "string") {
26
+ const trimmed = openApiConfig.trim();
27
+ if (trimmed) {
28
+ target.add(trimmed);
29
+ }
30
+ } else if (
31
+ isRecord(openApiConfig) &&
32
+ typeof openApiConfig.source === "string"
33
+ ) {
34
+ const trimmed = openApiConfig.source.trim();
35
+ if (trimmed) {
36
+ target.add(trimmed);
37
+ }
38
+ }
39
+
40
+ const menu = navigationNode.menu;
41
+ if (!isRecord(menu) || !Array.isArray(menu.items)) {
42
+ return;
43
+ }
44
+
45
+ for (const menuItem of menu.items) {
46
+ if (!isRecord(menuItem) || !isRecord(menuItem.submenu)) {
47
+ continue;
48
+ }
49
+ collectOpenApiSourcesFromNavigation(menuItem.submenu, target);
50
+ }
51
+ }
52
+
53
+ function resolveServerTemplateUrl(rawUrl, rawVariables) {
54
+ let unresolved = false;
55
+ const variables = isRecord(rawVariables) ? rawVariables : null;
56
+
57
+ const resolved = rawUrl.replace(/\{([^}]+)\}/g, (_match, tokenName) => {
58
+ if (!variables) {
59
+ unresolved = true;
60
+ return "";
61
+ }
62
+
63
+ const variableConfig = variables[tokenName];
64
+ if (
65
+ !isRecord(variableConfig) ||
66
+ typeof variableConfig.default !== "string"
67
+ ) {
68
+ unresolved = true;
69
+ return "";
70
+ }
71
+
72
+ const defaultValue = variableConfig.default.trim();
73
+ if (!defaultValue) {
74
+ unresolved = true;
75
+ return "";
76
+ }
77
+
78
+ return defaultValue;
79
+ });
80
+
81
+ if (unresolved) {
82
+ return null;
83
+ }
84
+
85
+ return resolved;
86
+ }
87
+
88
+ function normalizeAllowedOrigin(rawUrl) {
89
+ let parsed;
90
+ try {
91
+ parsed = new URL(rawUrl);
92
+ } catch {
93
+ return null;
94
+ }
95
+
96
+ if (parsed.protocol !== "https:") {
97
+ return null;
98
+ }
99
+
100
+ if (!parsed.hostname || parsed.username || parsed.password) {
101
+ return null;
102
+ }
103
+
104
+ return parsed.origin.toLowerCase();
105
+ }
106
+
107
+ async function loadOpenApiSpec(source) {
108
+ let fileContent;
109
+
110
+ if (isHttpUrl(source)) {
111
+ const response = await fetch(source);
112
+ if (!response.ok) {
113
+ throw new Error(
114
+ `Failed to fetch OpenAPI spec (${response.status} ${response.statusText})`,
115
+ );
116
+ }
117
+ fileContent = await response.text();
118
+ } else {
119
+ const absolutePath = path.join(DOCS_DIR, source);
120
+ fileContent = await fs.readFile(absolutePath, "utf8");
121
+ }
122
+
123
+ const trimmed = fileContent.trim();
124
+ if (trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html")) {
125
+ throw new Error("OpenAPI source returned HTML instead of JSON or YAML");
126
+ }
127
+
128
+ try {
129
+ return JSON.parse(fileContent);
130
+ } catch {
131
+ return YAML.parse(fileContent);
132
+ }
133
+ }
134
+
135
+ async function buildAllowedOrigins() {
136
+ const docsConfigRaw = await fs.readFile(DOCS_CONFIG_PATH, "utf8");
137
+ const docsConfig = JSON.parse(docsConfigRaw);
138
+
139
+ if (!isRecord(docsConfig) || !isRecord(docsConfig.navigation)) {
140
+ return [];
141
+ }
142
+
143
+ const sources = new Set();
144
+ collectOpenApiSourcesFromNavigation(docsConfig.navigation, sources);
145
+ if (sources.size === 0) {
146
+ return [];
147
+ }
148
+
149
+ const allowedOrigins = new Set();
150
+
151
+ for (const source of sources) {
152
+ try {
153
+ const spec = await loadOpenApiSpec(source);
154
+ const servers =
155
+ isRecord(spec) && Array.isArray(spec.servers) ? spec.servers : [];
156
+
157
+ for (const server of servers) {
158
+ if (!isRecord(server) || typeof server.url !== "string") {
159
+ continue;
160
+ }
161
+
162
+ const resolvedUrl = resolveServerTemplateUrl(
163
+ server.url,
164
+ server.variables,
165
+ );
166
+ if (!resolvedUrl) {
167
+ continue;
168
+ }
169
+
170
+ const normalizedOrigin = normalizeAllowedOrigin(resolvedUrl);
171
+ if (normalizedOrigin) {
172
+ allowedOrigins.add(normalizedOrigin);
173
+ }
174
+ }
175
+ } catch (error) {
176
+ console.warn(
177
+ `⚠️ Failed to extract OpenAPI server origins from "${source}":`,
178
+ error instanceof Error ? error.message : String(error),
179
+ );
180
+ }
181
+ }
182
+
183
+ return Array.from(allowedOrigins).sort();
184
+ }
185
+
186
+ async function run() {
187
+ let allowedOrigins = [];
188
+
189
+ try {
190
+ allowedOrigins = await buildAllowedOrigins();
191
+ } catch (error) {
192
+ console.warn(
193
+ "⚠️ Failed to generate proxy allowed origins. Falling back to empty allowlist.",
194
+ error instanceof Error ? error.message : String(error),
195
+ );
196
+ allowedOrigins = [];
197
+ }
198
+
199
+ await fs.mkdir(OUTPUT_DIR, { recursive: true });
200
+ await fs.writeFile(
201
+ OUTPUT_FILE,
202
+ `${JSON.stringify(allowedOrigins, null, 2)}\n`,
203
+ "utf8",
204
+ );
205
+
206
+ console.log(
207
+ `✅ Wrote ${allowedOrigins.length} proxy allowed origins to ${path.relative(CWD, OUTPUT_FILE)}`,
208
+ );
209
+ }
210
+
211
+ run().catch((error) => {
212
+ console.error(
213
+ "❌ Failed to write proxy allowed origins manifest:",
214
+ error instanceof Error ? error.message : String(error),
215
+ );
216
+ process.exit(1);
217
+ });
@@ -0,0 +1,19 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const CWD = process.cwd();
5
+ const DIST_DIR = path.join(CWD, "dist");
6
+ const ROBOTS_TXT_PATH = path.join(DIST_DIR, "robots.txt");
7
+
8
+ function main() {
9
+ if (!fs.existsSync(DIST_DIR)) {
10
+ console.log("Skipping robots.txt generation: dist directory not found.");
11
+ return;
12
+ }
13
+
14
+ const robotsTxt = "User-agent: *\nAllow: /\nSitemap: /sitemap-index.xml\n";
15
+ fs.writeFileSync(ROBOTS_TXT_PATH, robotsTxt, "utf8");
16
+ console.log("✅ robots.txt generated.");
17
+ }
18
+
19
+ main();
@@ -6,6 +6,28 @@ const CWD = process.cwd();
6
6
  const DIST_DIR = path.join(CWD, "dist");
7
7
  const VERSION_LENGTH = 12;
8
8
  const DIST_ROOT = path.resolve(DIST_DIR);
9
+ const CONFIGURED_STATIC_HOST = (() => {
10
+ const raw =
11
+ typeof process.env.STATIC_ASSET_HOST === "string"
12
+ ? process.env.STATIC_ASSET_HOST.trim()
13
+ : "";
14
+ if (!raw) return null;
15
+ const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(raw)
16
+ ? raw
17
+ : `https://${raw}`;
18
+
19
+ try {
20
+ const parsed = new URL(withScheme);
21
+ return parsed.hostname.toLowerCase();
22
+ } catch {
23
+ return null;
24
+ }
25
+ })();
26
+ const CONFIGURED_PREFIX =
27
+ typeof process.env.R2_BUCKET_PREFIX === "string" &&
28
+ process.env.R2_BUCKET_PREFIX.trim().length > 0
29
+ ? process.env.R2_BUCKET_PREFIX.trim().replace(/^\/+|\/+$/g, "")
30
+ : null;
9
31
  const VERSIONED_EXTENSIONS = new Set([
10
32
  ".avif",
11
33
  ".gif",
@@ -76,6 +98,24 @@ function getHash(filePath) {
76
98
  return hash;
77
99
  }
78
100
 
101
+ function normalizePathForDistLookup(pathname) {
102
+ const normalizedPathname = path.posix.normalize(pathname);
103
+ if (!CONFIGURED_PREFIX) {
104
+ return normalizedPathname;
105
+ }
106
+
107
+ const prefix = `/${CONFIGURED_PREFIX}`;
108
+ if (
109
+ normalizedPathname === prefix ||
110
+ normalizedPathname.startsWith(`${prefix}/`)
111
+ ) {
112
+ const stripped = normalizedPathname.slice(prefix.length);
113
+ return stripped.startsWith("/") ? stripped : `/${stripped}`;
114
+ }
115
+
116
+ return normalizedPathname;
117
+ }
118
+
79
119
  function resolveLocalImagePath(urlValue, filePath) {
80
120
  const trimmed = urlValue.trim();
81
121
  if (!trimmed) return null;
@@ -91,11 +131,6 @@ function resolveLocalImagePath(urlValue, filePath) {
91
131
  return null;
92
132
  }
93
133
 
94
- // Ignore fully-qualified URLs.
95
- if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(decoded)) {
96
- return null;
97
- }
98
-
99
134
  let parsed;
100
135
  try {
101
136
  parsed = new URL(decoded, `https://radiant.invalid${htmlBasePathname(filePath)}`);
@@ -103,13 +138,27 @@ function resolveLocalImagePath(urlValue, filePath) {
103
138
  return null;
104
139
  }
105
140
 
141
+ const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(decoded);
142
+ if (isAbsoluteUrl) {
143
+ if (!CONFIGURED_STATIC_HOST) {
144
+ return null;
145
+ }
146
+
147
+ if (parsed.protocol !== "https:") {
148
+ return null;
149
+ }
150
+
151
+ if (parsed.hostname.toLowerCase() !== CONFIGURED_STATIC_HOST) {
152
+ return null;
153
+ }
154
+ }
155
+
106
156
  const extension = path.extname(parsed.pathname).toLowerCase();
107
157
  if (!VERSIONED_EXTENSIONS.has(extension)) {
108
158
  return null;
109
159
  }
110
160
 
111
- const normalizedPathname = path.posix
112
- .normalize(parsed.pathname)
161
+ const normalizedPathname = normalizePathForDistLookup(parsed.pathname)
113
162
  .replace(/^\/+/, "");
114
163
  const absolutePath = path.resolve(DIST_DIR, normalizedPathname);
115
164
 
@@ -124,12 +173,15 @@ function resolveLocalImagePath(urlValue, filePath) {
124
173
  return null;
125
174
  }
126
175
 
127
- return { parsed, absolutePath };
176
+ return { parsed, absolutePath, isAbsoluteUrl };
128
177
  }
129
178
 
130
- function formatUpdatedUrl(parsed) {
179
+ function formatUpdatedUrl(parsed, isAbsoluteUrl) {
131
180
  const pathnameWithParams = `${parsed.pathname}${parsed.search}${parsed.hash}`;
132
- return pathnameWithParams.replace(/&/g, "&amp;");
181
+ const fullValue = isAbsoluteUrl
182
+ ? `${parsed.origin}${pathnameWithParams}`
183
+ : pathnameWithParams;
184
+ return fullValue.replace(/&/g, "&amp;");
133
185
  }
134
186
 
135
187
  function stampSingleUrl(urlValue, filePath) {
@@ -141,7 +193,7 @@ function stampSingleUrl(urlValue, filePath) {
141
193
  const version = getHash(resolved.absolutePath);
142
194
  resolved.parsed.searchParams.set("v", version);
143
195
 
144
- const updated = formatUpdatedUrl(resolved.parsed);
196
+ const updated = formatUpdatedUrl(resolved.parsed, resolved.isAbsoluteUrl);
145
197
  return { value: updated, changed: updated !== urlValue };
146
198
  }
147
199
 
@@ -58,14 +58,14 @@ const config = await getConfig();
58
58
  showAskAiTrigger ? (
59
59
  <button
60
60
  type="button"
61
- class="hidden md:inline-flex items-center gap-1.5 h-8 rounded-lg [corner-shape:superellipse(1.2)] bg-linear-to-b from-neutral-900/85 to-neutral-900 px-3 text-xs text-white shadow-sm dark:bg-white dark:text-neutral-900 cursor-pointer"
61
+ class="hidden md:inline-flex items-center gap-2 h-8 rounded-lg [corner-shape:superellipse(1.2)] bg-linear-to-b from-neutral-900/85 to-neutral-900 px-3 text-[13px] font-[350] text-white shadow-sm dark:bg-white dark:text-neutral-900 cursor-pointer"
62
62
  onclick="window.dispatchEvent(new CustomEvent('ask-ai:open'));"
63
63
  >
64
64
  <img
65
65
  src={sparkleIcon}
66
66
  alt=""
67
67
  aria-hidden="true"
68
- class="size-3 invert dark:invert-0"
68
+ class="size-3 -ml-px invert dark:invert-0"
69
69
  />
70
70
  Ask AI
71
71
  </button>
@@ -110,7 +110,7 @@ const config = await getConfig();
110
110
  {config.navbar.secondary && (
111
111
  <a
112
112
  class:list={[
113
- "h-[33px] items-center gap-1.5 px-3 text-[13px] bg-white/90 text-neutral-600/85 hover:text-neutral-600 rounded-lg [corner-shape:superellipse(1.2)] border border-neutral-200 shadow-xs hover:shadow-sm. transition-all whitespace-nowrap",
113
+ "h-[33px] items-center gap-1.5 px-[11px] text-[13px] bg-white/90 text-neutral-600/85 hover:text-neutral-600 rounded-lg [corner-shape:superellipse(1.2)] border border-neutral-200 shadow-xs hover:shadow-sm. transition-all whitespace-nowrap",
114
114
  config.navbar.primary ? "hidden lg:flex" : "flex",
115
115
  ]}
116
116
  href={config.navbar.secondary.href}
@@ -129,7 +129,7 @@ const config = await getConfig();
129
129
  {config.navbar.primary && (
130
130
  <a
131
131
  class:list={[
132
- "h-8 flex items-center gap-2 px-3 text-[13px] rounded-lg [corner-shape:superellipse(1.2)] bg-linear-to-b from-neutral-900/85 to-neutral-900 text-white duration-200 shadow-sm transition-all whitespace-nowrap",
132
+ "font-[350] h-8 flex items-center gap-2 px-3 text-[13px] rounded-lg [corner-shape:superellipse(1.2)] bg-linear-to-b from-neutral-900/85 to-neutral-900 text-white duration-200 shadow-sm transition-all whitespace-nowrap",
133
133
  ]}
134
134
  href={config.navbar.primary.href}
135
135
  >
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { getConfig, type LogoVariant } from "../lib/validation";
3
+ import { resolveStaticAssetUrl } from "../lib/static-asset-url";
3
4
 
4
5
  const config = await getConfig();
5
6
 
@@ -12,7 +13,7 @@ function getLogoUrl(logoPath: string | undefined): string | null {
12
13
  : logoPath;
13
14
 
14
15
  // Return public URL - Vite plugin ensures file exists in public
15
- return `/${normalizedPath}`;
16
+ return resolveStaticAssetUrl(`/${normalizedPath}`);
16
17
  }
17
18
 
18
19
  function resolveLogoVariant(
@@ -72,7 +72,7 @@ const currentPrefix = parentSlug
72
72
  <div
73
73
  class:list={[
74
74
  "mt-3 mx-2 z-10 relative",
75
- menu.label && "rounded-lg bg-neutral-100 p-[3px]",
75
+ menu.label && "rounded-lg bg-neutral-100 dark:bg-neutral-800/60 p-[3px]",
76
76
  ]}
77
77
  >
78
78
  {
@@ -84,7 +84,7 @@ const currentPrefix = parentSlug
84
84
  }
85
85
  <div class="relative">
86
86
  <button
87
- class="flex items-center w-full text-sm text-neutral-700 bg-white border-t border-x border-neutral-200/70 rounded-lg shadow-sm shadow-neutral-200 px-3 py-2 cursor-pointer"
87
+ class="flex items-center w-full text-sm text-neutral-700 dark:text-neutral-200 bg-white dark:bg-neutral-700/50 border-t border-x border-neutral-200/70 dark:border-neutral-700/40 dark:border-b rounded-lg shadow-xs px-3 py-2 cursor-pointer"
88
88
  x-on:click="open = true"
89
89
  aria-haspopup="menu"
90
90
  aria-expanded
@@ -104,7 +104,7 @@ const currentPrefix = parentSlug
104
104
  </div>
105
105
  <ul
106
106
  class:list={[
107
- "z-50 absolute bg-white border border-neutral-200 rounded-lg inset-x-0 top-full py-[3px] shadow-xl overflow-hidden",
107
+ "z-50 absolute bg-white dark:bg-neutral-700/50 border border-neutral-200 rounded-lg inset-x-0 top-full py-[3px] shadow-xl overflow-hidden",
108
108
  menu.label ? "mt-1.5" : "mt-1",
109
109
  ]}
110
110
  x-init
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import type { NavGroup } from "../lib/validation";
3
3
  import SidebarLink from "./SidebarLink.astro";
4
+ import SidebarOpenApiPageLink from "./sidebar/SidebarOpenApiPageLink.astro";
4
5
  import SidebarSubgroup from "./SidebarSubgroup.astro";
5
6
  import { slugify } from "../lib/utils";
6
7
  import Tag from "./ui/Tag.astro";
@@ -38,6 +39,8 @@ const currentPrefix = parentSlug ? `${parentSlug}/${groupSlug}` : groupSlug;
38
39
  title={child.title}
39
40
  groupSlug={currentPrefix}
40
41
  />
42
+ ) : "openapi" in child ? (
43
+ <SidebarOpenApiPageLink item={child} parentSlug={currentPrefix} />
41
44
  ) : (
42
45
  <SidebarSubgroup item={child} parentSlug={currentPrefix} />
43
46
  )}
@@ -1,6 +1,12 @@
1
1
  ---
2
- import type { NavGroup, NavigationItem, NavPage } from "../lib/validation";
2
+ import type {
3
+ NavGroup,
4
+ NavigationItem,
5
+ NavOpenApiPage,
6
+ NavPage,
7
+ } from "../lib/validation";
3
8
  import SidebarOpenApi from "./sidebar/SidebarOpenApi.astro";
9
+ import SidebarOpenApiPageLink from "./sidebar/SidebarOpenApiPageLink.astro";
4
10
  import SidebarDropdown from "./SidebarDropdown.astro";
5
11
  import SidebarSegmented from "./SidebarSegmented.astro";
6
12
  import SidebarGroup from "./SidebarGroup.astro";
@@ -24,6 +30,13 @@ let { navigation, parentSlug = "" } = Astro.props;
24
30
  </li>
25
31
  ) : "group" in item ? (
26
32
  <SidebarGroup item={item as NavGroup} parentSlug={parentSlug} />
33
+ ) : "openapi" in item ? (
34
+ <li>
35
+ <SidebarOpenApiPageLink
36
+ item={item as NavOpenApiPage}
37
+ parentSlug={parentSlug}
38
+ />
39
+ </li>
27
40
  ) : (
28
41
  <li>
29
42
  <SidebarLink
@@ -1,7 +1,13 @@
1
1
  ---
2
2
  import { getConfig, type NavGroup } from "../lib/validation";
3
3
  import SidebarLink from "./SidebarLink.astro";
4
- import { buildMdxPageHref, slugify } from "../lib/utils";
4
+ import SidebarOpenApiPageLink from "./sidebar/SidebarOpenApiPageLink.astro";
5
+ import {
6
+ buildMdxPageHref,
7
+ buildOpenApiEndpointHref,
8
+ parseOpenApiEndpoint,
9
+ slugify,
10
+ } from "../lib/utils";
5
11
  import Tag from "./ui/Tag.astro";
6
12
  import Icon from "./ui/Icon.astro";
7
13
 
@@ -21,22 +27,37 @@ const listId = `list-${groupId}`;
21
27
 
22
28
  // Check if any child page is active (shallow check, no recursion needed)
23
29
  const containsActivePage = item.pages.some((child) => {
24
- const pagePath =
25
- typeof child === "string" ? child : "pages" in child ? null : child.page;
30
+ let href: string | null = null;
26
31
 
27
- if (pagePath) {
28
- const href = buildMdxPageHref({
29
- filePath: pagePath,
32
+ if (typeof child === "string") {
33
+ href = buildMdxPageHref({
34
+ filePath: child,
30
35
  groupSlug: currentPrefix,
31
36
  homePath: config.home,
32
37
  });
33
-
34
- // Normalize paths for comparison (remove trailing slashes)
35
- const normalizedCurrent = Astro.url.pathname.replace(/\/$/, "");
36
- const normalizedTarget = href.replace(/\/$/, "");
37
- return normalizedCurrent === normalizedTarget;
38
+ } else if ("page" in child) {
39
+ href = buildMdxPageHref({
40
+ filePath: child.page,
41
+ groupSlug: currentPrefix,
42
+ homePath: config.home,
43
+ });
44
+ } else if ("openapi" in child) {
45
+ const parsedEndpoint = parseOpenApiEndpoint(child.openapi.endpoint);
46
+ if (parsedEndpoint) {
47
+ href = buildOpenApiEndpointHref({
48
+ path: parsedEndpoint.path,
49
+ method: parsedEndpoint.method,
50
+ groupSlug: currentPrefix,
51
+ });
52
+ }
38
53
  }
39
- return false;
54
+
55
+ if (!href) return false;
56
+
57
+ // Normalize paths for comparison (remove trailing slashes)
58
+ const normalizedCurrent = Astro.url.pathname.replace(/\/$/, "");
59
+ const normalizedTarget = href.replace(/\/$/, "");
60
+ return normalizedCurrent === normalizedTarget;
40
61
  });
41
62
  ---
42
63
 
@@ -100,6 +121,8 @@ const containsActivePage = item.pages.some((child) => {
100
121
  title={child.title}
101
122
  groupSlug={currentPrefix}
102
123
  />
124
+ ) : "openapi" in child ? (
125
+ <SidebarOpenApiPageLink item={child} parentSlug={currentPrefix} />
103
126
  ) : null}
104
127
  </li>
105
128
  ))
@@ -1496,8 +1496,12 @@ export default function AskAiWidget({
1496
1496
  </form>
1497
1497
  </>
1498
1498
  ) : (
1499
- <div className={mobile ? "flex-1 px-4 py-4" : "px-4 py-4"}>
1500
- <p className="text-sm text-neutral-600 dark:text-neutral-300">
1499
+ <div className="flex-1 px-4 py-4 flex items-center justify-center">
1500
+ <p className="text-sm text-neutral-600 dark:text-neutral-300 flex justify-center gap-1.5">
1501
+ <Icon
1502
+ icon="lucide:circle-alert"
1503
+ className="size-5 shrink-0 self-start justify-self-start mt-0.5 block"
1504
+ />
1501
1505
  {unavailableMessage}
1502
1506
  </p>
1503
1507
  </div>
@@ -1703,7 +1707,7 @@ export default function AskAiWidget({
1703
1707
  }
1704
1708
  `}</style>
1705
1709
  {!isOpen ? (
1706
- <div className="fixed bottom-5 right-6 size-12 z-40 bg-white hover:scale-105 transition duration-300 ease-in-out">
1710
+ <div className="fixed bottom-5 right-6 size-12 z-40 bg-white rounded-full hover:scale-105 transition duration-300 ease-in-out">
1707
1711
  <button
1708
1712
  type="button"
1709
1713
  className="w-full h-full inline-flex items-center justify-center gap-2 rounded-full bg-linear-to-b from-neutral-900/85 to-neutral-900 shadow-xl dark:bg-white cursor-pointer"
@@ -38,10 +38,10 @@ import { Icon } from "astro-icon/components";
38
38
  >
39
39
  <button
40
40
  x-on:click="open = true"
41
- 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 transition-all whitespace-nowrap cursor-pointer relative before:absolute before:inset-0 before:shadow-sm before:rounded-lg before:bg-transparent"
41
+ class="font-[350] m-px 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"
42
42
  >
43
+ <Icon class="-ml-px size-3.5" name="lucide:square-mouse-pointer" />
43
44
  Try it
44
- <Icon class="ml-2" name="lucide:square-mouse-pointer" />
45
45
  </button>
46
46
  <div
47
47
  x-show="open"
@@ -37,11 +37,7 @@ const configuredProxyUrl =
37
37
  typeof import.meta.env.PUBLIC_PROXY_URL === "string"
38
38
  ? import.meta.env.PUBLIC_PROXY_URL.trim()
39
39
  : "";
40
- const proxyUrl =
41
- configuredProxyUrl ||
42
- (import.meta.env.DEV
43
- ? "https://docs-proxy-dev.stefanjoseph-dev.workers.dev"
44
- : "");
40
+ const proxyUrl = configuredProxyUrl || "/_platform/proxy";
45
41
  const proxyEnabled = config.playground?.proxy !== false && proxyUrl.length > 0;
46
42
  const formattedBodyDescription = bodyDescription
47
43
  ? await renderMarkdown(bodyDescription)
@@ -544,20 +540,22 @@ const sectionVariantFieldNames = Object.fromEntries(
544
540
  <button
545
541
  @click="sendRequest($event)"
546
542
  :disabled="loading"
547
- 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"
548
544
  >
549
- <span
550
- class="flex items-center gap-2"
551
- x-bind:class="loading ? 'opacity-0':''"
552
- >
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
+ />
553
557
  Send <span class="hidden xs:inline">Request</span>
554
- <Icon class="size-4" name="lucide:square-arrow-up-right" />
555
558
  </span>
556
- <Icon
557
- x-show="loading"
558
- class="size-4 absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 animate-spin **:stroke-3"
559
- name="lucide:loader"
560
- />
561
559
  </button>
562
560
  </PlaygroundBar>
563
561
 
@@ -584,7 +582,7 @@ const sectionVariantFieldNames = Object.fromEntries(
584
582
  }
585
583
 
586
584
  return (
587
- <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">
588
586
  <Accordion title={headers[key]} defaultOpen titleSize="xl">
589
587
  {key === "body" && formattedBodyDescription && (
590
588
  <div