radiant-docs 0.1.38 → 0.1.40

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 (35) hide show
  1. package/package.json +1 -1
  2. package/template/astro.config.mjs +38 -7
  3. package/template/package-lock.json +19 -7
  4. package/template/package.json +1 -1
  5. package/template/scripts/generate-robots-txt.mjs +29 -1
  6. package/template/scripts/stamp-image-versions.mjs +59 -33
  7. package/template/src/components/Footer.astro +2 -1
  8. package/template/src/components/Header.astro +8 -6
  9. package/template/src/components/LogoLink.astro +2 -1
  10. package/template/src/components/MdxPage.astro +15 -4
  11. package/template/src/components/PagePagination.astro +61 -0
  12. package/template/src/components/SidebarDropdown.astro +12 -8
  13. package/template/src/components/SidebarGroup.astro +1 -1
  14. package/template/src/components/SidebarMenu.astro +1 -1
  15. package/template/src/components/SidebarSegmented.astro +6 -5
  16. package/template/src/components/TableOfContents.astro +4 -13
  17. package/template/src/components/chat/AskAiWidget.tsx +274 -39
  18. package/template/src/components/endpoint/PlaygroundForm.astro +2 -1
  19. package/template/src/components/user/CodeBlock.astro +8 -5
  20. package/template/src/components/user/CodeGroup.astro +262 -14
  21. package/template/src/components/user/ComponentPreviewBlock.astro +4 -3
  22. package/template/src/components/user/Image.astro +43 -53
  23. package/template/src/components/user/Tabs.astro +128 -23
  24. package/template/src/layouts/Layout.astro +217 -7
  25. package/template/src/lib/base-path.ts +98 -0
  26. package/template/src/lib/component-error.ts +49 -10
  27. package/template/src/lib/mdx/remark-resolve-internal-links.ts +128 -18
  28. package/template/src/lib/pagefind.ts +62 -14
  29. package/template/src/lib/routes.ts +49 -1
  30. package/template/src/lib/static-asset-url.ts +3 -1
  31. package/template/src/lib/utils.ts +12 -4
  32. package/template/src/lib/validation.ts +376 -36
  33. package/template/src/pages/404.astro +2 -1
  34. package/template/src/pages/[...slug].astro +68 -6
  35. package/template/src/styles/global.css +85 -1
@@ -27,17 +27,55 @@ if (labels.length === 0) {
27
27
 
28
28
  <div x-data="{
29
29
  activeTab: 0,
30
+ previousTab: null,
31
+ transitionDirection: 1,
32
+ isTransitioning: false,
33
+ transitionDurationMs: 300,
34
+ transitionTimeout: null,
30
35
  containerHeight: 'auto',
31
36
  markerStyle: { left: null, width: null },
37
+ resizeHandler: null,
32
38
  init() {
39
+ this.resizeHandler = () => {
40
+ this.updateMarker(this.activeTab);
41
+ this.updateHeight(this.isTransitioning);
42
+ };
43
+ window.addEventListener('resize', this.resizeHandler);
44
+
33
45
  this.$nextTick(() => {
34
46
  this.updateMarker(this.activeTab);
35
47
  this.updateHeight();
36
48
  });
37
- this.$watch('activeTab', (value) => {
38
- this.updateMarker(value);
49
+ },
50
+ destroy() {
51
+ if (this.resizeHandler) {
52
+ window.removeEventListener('resize', this.resizeHandler);
53
+ }
54
+ if (this.transitionTimeout) {
55
+ window.clearTimeout(this.transitionTimeout);
56
+ }
57
+ },
58
+ selectTab(index) {
59
+ if (index === this.activeTab) return;
60
+
61
+ if (this.transitionTimeout) {
62
+ window.clearTimeout(this.transitionTimeout);
63
+ this.transitionTimeout = null;
64
+ }
65
+
66
+ this.previousTab = this.activeTab;
67
+ this.transitionDirection = index > this.activeTab ? 1 : -1;
68
+ this.isTransitioning = true;
69
+ this.activeTab = index;
70
+ this.updateMarker(this.activeTab);
71
+ this.updateHeight(true);
72
+
73
+ this.transitionTimeout = window.setTimeout(() => {
74
+ this.isTransitioning = false;
75
+ this.previousTab = null;
76
+ this.transitionTimeout = null;
39
77
  this.updateHeight();
40
- });
78
+ }, this.transitionDurationMs);
41
79
  },
42
80
  updateMarker(index) {
43
81
  const el = this.$refs['tab-' + index];
@@ -48,20 +86,54 @@ if (labels.length === 0) {
48
86
  };
49
87
  }
50
88
  },
51
- updateHeight() {
89
+ getPanelStyle(index) {
90
+ const base = 'position: absolute; inset: 0;';
91
+
92
+ const isActive = index === this.activeTab;
93
+ const isPrevious = this.isTransitioning && index === this.previousTab;
94
+
95
+ if (!isActive && !isPrevious) {
96
+ return `${base} opacity: 0; pointer-events: none; visibility: hidden; z-index: 0;`;
97
+ }
98
+
99
+ if (!this.isTransitioning) {
100
+ return 'position: relative; transform: translateX(0); opacity: 1; pointer-events: auto; visibility: visible; z-index: 1;';
101
+ }
102
+
103
+ if (isActive) {
104
+ const animationName =
105
+ this.transitionDirection === 1
106
+ ? 'rd-tabs-slide-in-from-right'
107
+ : 'rd-tabs-slide-in-from-left';
108
+ return `${base} opacity: 1; pointer-events: auto; visibility: visible; z-index: 2; animation: ${animationName} ${this.transitionDurationMs}ms ease-in-out both;`;
109
+ }
110
+
111
+ const animationName =
112
+ this.transitionDirection === 1
113
+ ? 'rd-tabs-slide-out-to-left'
114
+ : 'rd-tabs-slide-out-to-right';
115
+ return `${base} opacity: 1; pointer-events: none; visibility: visible; z-index: 1; animation: ${animationName} ${this.transitionDurationMs}ms ease-in-out both;`;
116
+ },
117
+ updateHeight(includePrevious = false) {
52
118
  this.$nextTick(() => {
53
- // We look for the internal wrapper or the content div specifically
54
119
  const activeSlide = this.$refs['content-' + this.activeTab];
55
- if (activeSlide) {
56
- // scrollHeight is often more reliable than offsetHeight for hidden overflow
57
- this.containerHeight = activeSlide.scrollHeight + 'px';
120
+ if (!activeSlide) return;
121
+
122
+ let nextHeight = activeSlide.scrollHeight;
123
+ if (includePrevious && this.previousTab !== null) {
124
+ const previousSlide = this.$refs['content-' + this.previousTab];
125
+ if (previousSlide) {
126
+ nextHeight = Math.max(nextHeight, previousSlide.scrollHeight);
127
+ }
58
128
  }
129
+
130
+ this.containerHeight = nextHeight + 'px';
59
131
  });
60
132
  }
61
133
  }"
62
134
  class="my-5">
63
135
  <ul
64
- class="relative isolate not-prose flex w-fit rounded-lg border border-neutral-200 bg-neutral-100/80 p-[3px] inset-shadow-sm dark:border-none dark:bg-neutral-800/50"
136
+ class="relative isolate not-prose flex w-full max-w-full min-w-0 rounded-lg border border-neutral-100 bg-neutral-100/80 p-[3px] dark:border-none dark:bg-neutral-800/50"
65
137
  >
66
138
  <div
67
139
  class="absolute top-[3px] bottom-[3px] -z-10 flex items-center justify-center rounded-md bg-white shadow-sm transition-all duration-300 ease-out dark:bg-neutral-700/30 dark:border dark:border-neutral-700/40 dark:shadow-black/30."
@@ -74,12 +146,12 @@ class="my-5">
74
146
  </div>
75
147
 
76
148
  { labels.map((label, index) => (
77
- <li>
149
+ <li class="min-w-0 flex-1">
78
150
  <button
79
151
  type="button"
80
152
  x-ref={`tab-${index}`}
81
- @click={`activeTab = ${index}`}
82
- class="relative px-3 h-[32px] font-medium text-sm transition-colors duration-200 cursor-pointer text-nowrap flex items-center gap-2"
153
+ @click={`selectTab(${index})`}
154
+ class="relative flex h-[32px] w-full min-w-0 max-w-full cursor-pointer items-center gap-2 px-3 text-sm font-medium transition-colors duration-200"
83
155
  style={index === 0 ? "" : ""}
84
156
  class:list={[index === 0 ? "text-foreground" : "text-muted-foreground"]}
85
157
  :class={`{
@@ -88,30 +160,63 @@ class="my-5">
88
160
  }`}
89
161
  >
90
162
  {icons[index] && <Icon name={icons[index]} class="size-4 shrink-0" />}
91
- {label}
163
+ <span class="min-w-0 flex-1 truncate" title={label}>{label}</span>
92
164
  </button>
93
165
  </li>
94
166
  )) }
95
167
  </ul>
96
168
 
97
169
  <div
98
- class="mt-4 overflow-hidden transition-[height] duration-300 ease-in-out"
170
+ class="relative mt-4 overflow-hidden transition-[height] duration-300 ease-in-out"
99
171
  :style="'height: ' + containerHeight"
100
172
  >
101
- <div
102
- class="flex items-start transition-transform duration-300 ease-in-out"
103
- :style="'transform: translateX(-' + (activeTab * 100) + '%)'"
104
- >
105
173
  { tabContents.map((content, index) => (
106
- // We add a ref here so we can measure the height
107
174
  <div
175
+ {...(index !== 0 ? { "x-cloak": true } : {})}
108
176
  x-ref={`content-${index}`}
109
- class="w-full shrink-0 transition-opacity duration-300 ease-in-out"
110
- :style={`activeTab === ${index} ? 'opacity: 1' : 'opacity: 0 pointer-events-none'`}
111
- style={index === 0 ? 'opacity: 1' : 'opacity: 0'}
177
+ class="w-full"
178
+ :style={`getPanelStyle(${index})`}
179
+ style={index === 0 ? 'position: relative;' : ''}
112
180
  set:html={content}
113
181
  />
114
182
  )) }
115
- </div>
116
183
  </div>
117
184
  </div>
185
+
186
+ <style>
187
+ @keyframes rd-tabs-slide-in-from-right {
188
+ from {
189
+ transform: translateX(100%);
190
+ }
191
+ to {
192
+ transform: translateX(0);
193
+ }
194
+ }
195
+
196
+ @keyframes rd-tabs-slide-in-from-left {
197
+ from {
198
+ transform: translateX(-100%);
199
+ }
200
+ to {
201
+ transform: translateX(0);
202
+ }
203
+ }
204
+
205
+ @keyframes rd-tabs-slide-out-to-left {
206
+ from {
207
+ transform: translateX(0);
208
+ }
209
+ to {
210
+ transform: translateX(-100%);
211
+ }
212
+ }
213
+
214
+ @keyframes rd-tabs-slide-out-to-right {
215
+ from {
216
+ transform: translateX(0);
217
+ }
218
+ to {
219
+ transform: translateX(100%);
220
+ }
221
+ }
222
+ </style>
@@ -7,11 +7,18 @@ import googleSansLatinExtWoff2 from "../assets/fonts/google-sans-flex/latin-ext.
7
7
  import geistMonoLatinWoff2 from "../assets/fonts/geist-mono/latin.woff2?url";
8
8
  import geistMonoLatinExtWoff2 from "../assets/fonts/geist-mono/latin-ext.woff2?url";
9
9
  import Sidebar from "../components/Sidebar.astro";
10
- import { getConfig } from "../lib/validation";
10
+ import colors from "tailwindcss/colors";
11
+ import {
12
+ DEFAULT_THEME_COLOR_DARK,
13
+ DEFAULT_THEME_COLOR_LIGHT,
14
+ getConfig,
15
+ type BaseColorOption,
16
+ } from "../lib/validation";
11
17
  import Header from "../components/Header.astro";
12
18
  import Footer from "../components/Footer.astro";
13
19
  import AskAiWidget from "../components/chat/AskAiWidget";
14
20
  import { ClientRouter } from "astro:transitions";
21
+ import { prependBasePath, stripBasePath, withBasePath } from "../lib/base-path";
15
22
  import { resolveStaticAssetUrl } from "../lib/static-asset-url";
16
23
 
17
24
  interface Props {
@@ -36,6 +43,117 @@ function routePathToOgImagePath(routePath: string): string {
36
43
 
37
44
  const { pageTitle, pageDescription } = Astro.props as Props;
38
45
  const config = await getConfig();
46
+ const themeBaseColor = config.theme?.baseColor ?? "neutral";
47
+ const themeThemeColor = config.theme?.themeColor;
48
+ const lightBaseColor: BaseColorOption =
49
+ typeof themeBaseColor === "string" ? themeBaseColor : themeBaseColor.light;
50
+ const darkBaseColor: BaseColorOption =
51
+ typeof themeBaseColor === "string" ? themeBaseColor : themeBaseColor.dark;
52
+ const lightThemeColor =
53
+ typeof themeThemeColor === "string"
54
+ ? themeThemeColor
55
+ : (themeThemeColor?.light ?? DEFAULT_THEME_COLOR_LIGHT);
56
+ const darkThemeColor =
57
+ typeof themeThemeColor === "string"
58
+ ? themeThemeColor
59
+ : (themeThemeColor?.dark ?? DEFAULT_THEME_COLOR_DARK);
60
+ const neutralColorShades = [
61
+ "50",
62
+ "100",
63
+ "200",
64
+ "300",
65
+ "400",
66
+ "500",
67
+ "600",
68
+ "700",
69
+ "800",
70
+ "900",
71
+ "950",
72
+ ] as const;
73
+ const paletteColors = colors as unknown as Record<
74
+ string,
75
+ Record<(typeof neutralColorShades)[number], string>
76
+ >;
77
+ const getNeutralPaletteVariables = (baseColor: BaseColorOption): string[] => {
78
+ const palette = paletteColors[baseColor] ?? paletteColors.neutral;
79
+ return [
80
+ `--color-neutral: ${palette["500"]};`,
81
+ ...neutralColorShades.map(
82
+ (shade) => `--color-neutral-${shade}: ${palette[shade]};`,
83
+ ),
84
+ ];
85
+ };
86
+ function normalizeHexColorToRgb(
87
+ hexColor: string,
88
+ ): { r: number; g: number; b: number } | null {
89
+ const normalized = hexColor.replace("#", "").trim();
90
+ if (/^[a-fA-F0-9]{3,4}$/.test(normalized)) {
91
+ const [r, g, b] = normalized.split("");
92
+ if (!r || !g || !b) return null;
93
+ return {
94
+ r: Number.parseInt(`${r}${r}`, 16),
95
+ g: Number.parseInt(`${g}${g}`, 16),
96
+ b: Number.parseInt(`${b}${b}`, 16),
97
+ };
98
+ }
99
+
100
+ if (/^[a-fA-F0-9]{6,8}$/.test(normalized)) {
101
+ return {
102
+ r: Number.parseInt(normalized.slice(0, 2), 16),
103
+ g: Number.parseInt(normalized.slice(2, 4), 16),
104
+ b: Number.parseInt(normalized.slice(4, 6), 16),
105
+ };
106
+ }
107
+
108
+ return null;
109
+ }
110
+
111
+ function getThemeForegroundColor(hexColor: string): "#ffffff" | "#111827" {
112
+ const rgb = normalizeHexColorToRgb(hexColor);
113
+ if (!rgb) return "#ffffff";
114
+
115
+ const toLinear = (channel: number): number => {
116
+ const normalized = channel / 255;
117
+ return normalized <= 0.03928
118
+ ? normalized / 12.92
119
+ : ((normalized + 0.055) / 1.055) ** 2.4;
120
+ };
121
+
122
+ const luminance =
123
+ 0.2126 * toLinear(rgb.r) +
124
+ 0.7152 * toLinear(rgb.g) +
125
+ 0.0722 * toLinear(rgb.b);
126
+ return luminance > 0.45 ? "#111827" : "#ffffff";
127
+ }
128
+
129
+ const getThemeColorVariables = (themeColor: string): string[] => {
130
+ const foreground = getThemeForegroundColor(themeColor);
131
+ const iconFilter = foreground === "#ffffff" ? "invert(1)" : "none";
132
+
133
+ return [
134
+ `--color-theme: ${themeColor};`,
135
+ `--color-theme-foreground: ${foreground};`,
136
+ `--color-theme-icon-filter: ${iconFilter};`,
137
+ "--color-theme-top: color-mix(in oklab, var(--color-theme) 88%, white);",
138
+ "--color-theme-bottom: color-mix(in oklab, var(--color-theme) 90%, black);",
139
+ ];
140
+ };
141
+ const lightNeutralVariables = getNeutralPaletteVariables(lightBaseColor);
142
+ const darkNeutralVariables = getNeutralPaletteVariables(darkBaseColor);
143
+ const lightThemeColorVariables = getThemeColorVariables(lightThemeColor);
144
+ const darkThemeColorVariables = getThemeColorVariables(darkThemeColor);
145
+ const neutralPaletteCss = [
146
+ "html[data-theme='light'], html:not(.dark):not([data-theme='dark']) {",
147
+ ...[...lightNeutralVariables, ...lightThemeColorVariables].map(
148
+ (declaration) => ` ${declaration.replace(/;$/, " !important;")}`,
149
+ ),
150
+ "}",
151
+ "html.dark, html[data-theme='dark'] {",
152
+ ...[...darkNeutralVariables, ...darkThemeColorVariables].map(
153
+ (declaration) => ` ${declaration.replace(/;$/, " !important;")}`,
154
+ ),
155
+ "}",
156
+ ].join("\n");
39
157
  const resolvedPageTitle = pageTitle?.trim();
40
158
  const resolvedPageDescription =
41
159
  typeof pageDescription === "string" && pageDescription.trim().length > 0
@@ -48,12 +166,13 @@ const resolvedMetaDescription = resolvedPageDescription ?? fallbackDescription;
48
166
  const documentTitle = resolvedPageTitle
49
167
  ? `${resolvedPageTitle} | ${config.title}`
50
168
  : `${config.title} Docs`;
169
+ const routePathname = stripBasePath(Astro.url.pathname);
51
170
  const canonicalUrl = new URL(
52
- Astro.url.pathname,
171
+ prependBasePath(routePathname),
53
172
  Astro.site ?? Astro.url,
54
173
  ).toString();
55
174
  const ogImageUrl = new URL(
56
- resolveStaticAssetUrl(routePathToOgImagePath(Astro.url.pathname)),
175
+ resolveStaticAssetUrl(routePathToOgImagePath(routePathname)),
57
176
  Astro.site ?? Astro.url,
58
177
  );
59
178
  const ogImageHref = ogImageUrl.toString();
@@ -64,21 +183,21 @@ const parsedOrgTier = Number.parseInt(
64
183
  const orgTier =
65
184
  Number.isFinite(parsedOrgTier) && parsedOrgTier > 0 ? parsedOrgTier : 1;
66
185
  const isDev = import.meta.env.DEV;
67
- const askAiDevHost = (import.meta.env.ASK_AI_DEV_HOST ?? "").toString().trim();
68
- const askAiDevProxySecret = (import.meta.env.ASK_AI_DEV_PROXY_SECRET ?? "")
186
+ const askAiDevHost = (import.meta.env.ASK_AI_DEV_HOST ?? "s").toString().trim();
187
+ const askAiDevProxySecret = (import.meta.env.ASK_AI_DEV_PROXY_SECRET ?? "s")
69
188
  .toString()
70
189
  .trim();
71
190
  const hasAskAiDevConfig =
72
191
  askAiDevHost.length > 0 && askAiDevProxySecret.length > 0;
73
192
  const askAiEnabled = isDev || orgTier >= 3;
74
193
  const askAiChatAvailable = isDev ? hasAskAiDevConfig : orgTier >= 3;
75
- let askAiApiPath = "/_platform/ask-ai";
194
+ let askAiApiPath = withBasePath("/_platform/ask-ai");
76
195
 
77
196
  if (isDev && hasAskAiDevConfig) {
78
197
  try {
79
198
  askAiApiPath = new URL("/_platform/ask-ai", askAiDevHost).toString();
80
199
  } catch {
81
- askAiApiPath = "/_platform/ask-ai";
200
+ askAiApiPath = withBasePath("/_platform/ask-ai");
82
201
  }
83
202
  }
84
203
  ---
@@ -86,6 +205,7 @@ if (isDev && hasAskAiDevConfig) {
86
205
  <!doctype html>
87
206
  <html lang="en">
88
207
  <head>
208
+ <style is:inline is:global set:html={neutralPaletteCss}></style>
89
209
  <ClientRouter />
90
210
  <script is:inline>
91
211
  const applyTheme = () => {
@@ -115,6 +235,96 @@ if (isDev && hasAskAiDevConfig) {
115
235
  // Run on initial load
116
236
  applyTheme();
117
237
  </script>
238
+ <script is:inline>
239
+ (() => {
240
+ const isEmbedded = window.self !== window.top;
241
+ const isEmbedMode =
242
+ new URLSearchParams(window.location.search).get("embed") === "1";
243
+
244
+ if (!isEmbedMode || !isEmbedded) {
245
+ if (isEmbedMode && !isEmbedded) {
246
+ console.info("[iframe-ready] skipping: not embedded iframe");
247
+ }
248
+ return;
249
+ }
250
+
251
+ console.info("[iframe-ready] init", {
252
+ href: window.location.href,
253
+ embedded: isEmbedded,
254
+ });
255
+
256
+ const readySentSessionKey = "radiant_docs_iframe_ready_sent";
257
+ const hasSentReady = () => {
258
+ try {
259
+ const sent = sessionStorage.getItem(readySentSessionKey) === "1";
260
+ console.info("[iframe-ready] session flag read", {
261
+ key: readySentSessionKey,
262
+ sent,
263
+ });
264
+ return sent;
265
+ } catch {
266
+ console.warn(
267
+ "[iframe-ready] failed to read sessionStorage flag",
268
+ readySentSessionKey,
269
+ );
270
+ return false;
271
+ }
272
+ };
273
+ const markReadySent = () => {
274
+ try {
275
+ sessionStorage.setItem(readySentSessionKey, "1");
276
+ console.info("[iframe-ready] session flag set", {
277
+ key: readySentSessionKey,
278
+ value: "1",
279
+ });
280
+ } catch {
281
+ // Ignore storage failures (privacy mode, disabled storage, etc.)
282
+ console.warn(
283
+ "[iframe-ready] failed to set sessionStorage flag",
284
+ readySentSessionKey,
285
+ );
286
+ }
287
+ };
288
+
289
+ const notifyParentReady = () => {
290
+ const readyUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
291
+ console.info("[iframe-ready] posting IFRAME_READY", { readyUrl });
292
+ window.parent.postMessage(
293
+ {
294
+ type: "IFRAME_READY",
295
+ url: readyUrl,
296
+ },
297
+ "*",
298
+ );
299
+ };
300
+
301
+ const handleReadyRequest = (event) => {
302
+ const payload = event.data;
303
+ if (!payload || typeof payload !== "object") return;
304
+
305
+ if (payload.type === "IFRAME_READY_REQUEST") {
306
+ console.info("[iframe-ready] received IFRAME_READY_REQUEST");
307
+ notifyParentReady();
308
+ }
309
+ };
310
+
311
+ window.addEventListener("message", handleReadyRequest);
312
+
313
+ if (!hasSentReady()) {
314
+ const notifyParentReadyOnce = () => {
315
+ if (hasSentReady()) return;
316
+ markReadySent();
317
+ notifyParentReady();
318
+ };
319
+
320
+ if (document.readyState === "complete") notifyParentReadyOnce();
321
+ else
322
+ window.addEventListener("load", notifyParentReadyOnce, {
323
+ once: true,
324
+ });
325
+ }
326
+ })();
327
+ </script>
118
328
  <link
119
329
  rel="preload"
120
330
  href={googleSansLatinWoff2}
@@ -0,0 +1,98 @@
1
+ const EXTERNAL_PROTOCOL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
2
+
3
+ declare global {
4
+ // Set by astro.config.mjs so build-time MDX remark plugins can see the
5
+ // configured base before Astro injects import.meta.env.BASE_URL.
6
+ var __RADIANT_DOCS_BASE_PATH__: string | undefined;
7
+ }
8
+
9
+ export function normalizeBasePath(value: string | null | undefined): string {
10
+ const trimmed = value?.trim() ?? "";
11
+ if (!trimmed || trimmed === "/") return "";
12
+
13
+ const pathname = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
14
+ const normalized = pathname.replace(/\/{2,}/g, "/").replace(/\/+$/, "");
15
+ return normalized === "/" ? "" : normalized;
16
+ }
17
+
18
+ export function getDocsBasePath(): string {
19
+ const astroBasePath = normalizeBasePath(import.meta.env.BASE_URL);
20
+ return (
21
+ astroBasePath ||
22
+ normalizeBasePath(globalThis.__RADIANT_DOCS_BASE_PATH__)
23
+ );
24
+ }
25
+
26
+ function splitPathSuffix(value: string): { pathname: string; suffix: string } {
27
+ const match = value.match(/^([^?#]*)(.*)$/);
28
+ return {
29
+ pathname: match?.[1] ?? value,
30
+ suffix: match?.[2] ?? "",
31
+ };
32
+ }
33
+
34
+ function isExternalOrDocumentLocalHref(value: string): boolean {
35
+ if (!value) return true;
36
+ if (value.startsWith("#") || value.startsWith("?")) return true;
37
+ if (value.startsWith("//")) return true;
38
+ if (value.startsWith("./") || value.startsWith("../")) return true;
39
+ return EXTERNAL_PROTOCOL_REGEX.test(value);
40
+ }
41
+
42
+ function applyBasePath(
43
+ href: string,
44
+ options: { preserveAlreadyPrefixed: boolean },
45
+ ): string {
46
+ const value = href.trim();
47
+ if (!value || isExternalOrDocumentLocalHref(value)) {
48
+ return href;
49
+ }
50
+
51
+ const basePath = getDocsBasePath();
52
+ if (!basePath) {
53
+ return value;
54
+ }
55
+
56
+ const { pathname, suffix } = splitPathSuffix(value);
57
+ const normalizedPathname = pathname.startsWith("/")
58
+ ? pathname
59
+ : `/${pathname}`;
60
+
61
+ if (
62
+ options.preserveAlreadyPrefixed &&
63
+ (normalizedPathname === basePath ||
64
+ normalizedPathname.startsWith(`${basePath}/`))
65
+ ) {
66
+ return `${normalizedPathname}${suffix}`;
67
+ }
68
+
69
+ if (normalizedPathname === "/") {
70
+ return `${basePath}${suffix}`;
71
+ }
72
+
73
+ return `${basePath}${normalizedPathname}${suffix}`;
74
+ }
75
+
76
+ export function withBasePath(href: string): string {
77
+ return applyBasePath(href, { preserveAlreadyPrefixed: true });
78
+ }
79
+
80
+ export function prependBasePath(href: string): string {
81
+ return applyBasePath(href, { preserveAlreadyPrefixed: false });
82
+ }
83
+
84
+ export function stripBasePath(pathname: string): string {
85
+ const value = pathname || "/";
86
+ const basePath = getDocsBasePath();
87
+ if (!basePath) return value;
88
+
89
+ if (value === basePath) {
90
+ return "/";
91
+ }
92
+
93
+ if (value.startsWith(`${basePath}/`)) {
94
+ return value.slice(basePath.length) || "/";
95
+ }
96
+
97
+ return value;
98
+ }
@@ -85,27 +85,33 @@ export function validateType(
85
85
  componentName: string,
86
86
  propName: string,
87
87
  value: unknown,
88
- expectedType: "string" | "number" | "boolean" | "object" | "array",
88
+ expectedType:
89
+ | "string"
90
+ | "number"
91
+ | "boolean"
92
+ | "object"
93
+ | "array"
94
+ | readonly ("string" | "number" | "boolean" | "object" | "array")[],
89
95
  pathname: string
90
96
  ): void {
91
97
  // Skip if undefined (optional props)
92
98
  if (value === undefined) return;
93
99
 
94
- let isValid = false;
95
-
96
- if (expectedType === "array") {
97
- isValid = Array.isArray(value);
98
- } else {
99
- isValid = typeof value === expectedType;
100
- }
100
+ const expectedTypes = Array.isArray(expectedType)
101
+ ? expectedType
102
+ : [expectedType];
103
+ const isValid = expectedTypes.some((type) =>
104
+ type === "array" ? Array.isArray(value) : typeof value === type
105
+ );
101
106
 
102
107
  if (!isValid) {
103
108
  const sourceFile = getSourceFile(pathname);
104
109
  const actualType = Array.isArray(value) ? "array" : typeof value;
110
+ const expectedTypeLabel = expectedTypes.join(" or ");
105
111
  throw new Error(
106
112
  formatError(
107
113
  componentName,
108
- `Invalid prop "${propName}": expected ${expectedType}, got ${actualType}`,
114
+ `Invalid prop "${propName}": expected ${expectedTypeLabel}, got ${actualType}`,
109
115
  sourceFile
110
116
  )
111
117
  );
@@ -123,10 +129,43 @@ export function validateType(
123
129
  */
124
130
  export type PropSchema = {
125
131
  required?: boolean;
126
- type?: "string" | "number" | "boolean" | "object" | "array";
132
+ type?:
133
+ | "string"
134
+ | "number"
135
+ | "boolean"
136
+ | "object"
137
+ | "array"
138
+ | readonly ("string" | "number" | "boolean" | "object" | "array")[];
127
139
  enum?: readonly string[];
128
140
  };
129
141
 
142
+ /**
143
+ * Validates that no unsupported props are passed to a component.
144
+ */
145
+ export function validateNoUnknownProps(
146
+ componentName: string,
147
+ props: Record<string, unknown>,
148
+ allowedProps: readonly string[],
149
+ pathname: string
150
+ ): void {
151
+ const allowed = new Set(allowedProps);
152
+ const unknownProps = Object.keys(props).filter((key) => !allowed.has(key));
153
+
154
+ if (unknownProps.length === 0) return;
155
+
156
+ const sourceFile = getSourceFile(pathname);
157
+ const unknownLabel = unknownProps.map((name) => `"${name}"`).join(", ");
158
+ const allowedLabel = allowedProps.map((name) => `"${name}"`).join(", ");
159
+ const propLabel = unknownProps.length === 1 ? "prop" : "props";
160
+ throw new Error(
161
+ formatError(
162
+ componentName,
163
+ `Unsupported ${propLabel}: ${unknownLabel}. Allowed props: ${allowedLabel}`,
164
+ sourceFile
165
+ )
166
+ );
167
+ }
168
+
130
169
  export function validateProps(
131
170
  componentName: string,
132
171
  props: Record<string, unknown>,