radiant-docs 0.1.39 → 0.1.41

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 (49) 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 +3 -3
  5. package/template/public/favicon.svg +16 -8
  6. package/template/scripts/generate-robots-txt.mjs +29 -1
  7. package/template/scripts/remove-assistant-for-non-pro.mjs +28 -0
  8. package/template/scripts/stamp-image-versions.mjs +59 -33
  9. package/template/src/components/Footer.astro +2 -1
  10. package/template/src/components/Header.astro +10 -8
  11. package/template/src/components/LogoLink.astro +2 -1
  12. package/template/src/components/MdxPage.astro +15 -4
  13. package/template/src/components/PagePagination.astro +61 -0
  14. package/template/src/components/SidebarDropdown.astro +12 -8
  15. package/template/src/components/SidebarGroup.astro +1 -1
  16. package/template/src/components/SidebarMenu.astro +1 -1
  17. package/template/src/components/SidebarSegmented.astro +6 -5
  18. package/template/src/components/TableOfContents.astro +4 -13
  19. package/template/src/components/chat/AskAiWidget.tsx +274 -39
  20. package/template/src/components/chat/AssistantDocsWidget.astro +16 -0
  21. package/template/src/components/chat/AssistantDocsWidget.tsx +402 -0
  22. package/template/src/components/chat/AssistantEmbedPanel.tsx +1693 -0
  23. package/template/src/components/chat/AssistantEmbedPanelPage.astro +95 -0
  24. package/template/src/components/endpoint/PlaygroundForm.astro +2 -1
  25. package/template/src/components/user/Callout.astro +10 -4
  26. package/template/src/components/user/CodeBlock.astro +1 -1
  27. package/template/src/components/user/CodeGroup.astro +16 -1
  28. package/template/src/components/user/ComponentPreviewBlock.astro +1 -0
  29. package/template/src/components/user/Image.astro +43 -53
  30. package/template/src/layouts/Layout.astro +104 -35
  31. package/template/src/lib/assistant-chrome-defaults.ts +74 -0
  32. package/template/src/lib/assistant-chrome.ts +39 -0
  33. package/template/src/lib/assistant-embed-script.ts +897 -0
  34. package/template/src/lib/assistant-panel-config.ts +80 -0
  35. package/template/src/lib/base-path.ts +98 -0
  36. package/template/src/lib/component-error.ts +49 -10
  37. package/template/src/lib/favicon.ts +31 -0
  38. package/template/src/lib/mdx/remark-resolve-internal-links.ts +128 -18
  39. package/template/src/lib/pagefind.ts +62 -14
  40. package/template/src/lib/routes.ts +49 -1
  41. package/template/src/lib/static-asset-url.ts +3 -1
  42. package/template/src/lib/theme-css.ts +176 -0
  43. package/template/src/lib/utils.ts +12 -4
  44. package/template/src/lib/validation.ts +754 -37
  45. package/template/src/pages/-/assistant/embed.js.ts +15 -0
  46. package/template/src/pages/-/assistant/panel.astro +5 -0
  47. package/template/src/pages/404.astro +6 -5
  48. package/template/src/pages/[...slug].astro +68 -6
  49. package/template/src/styles/global.css +62 -1
@@ -0,0 +1,80 @@
1
+ import {
2
+ type AssistantChromeConfig,
3
+ getAssistantChromeConfig,
4
+ } from "./assistant-chrome";
5
+ import { withBasePath } from "./base-path";
6
+ import { getAssistantLauncherIconConfig } from "./assistant-embed-script";
7
+ import type { DocsConfig } from "./validation";
8
+
9
+ export type AssistantPanelRuntimeConfig = {
10
+ apiPath: string;
11
+ docsTitle: string;
12
+ isChatAvailable: boolean;
13
+ canSendChatRequest: boolean;
14
+ launcherThemeColor: string;
15
+ launcherThemeColors: {
16
+ light: string;
17
+ dark: string;
18
+ };
19
+ launcherIconColor: string;
20
+ launcherIconColors: {
21
+ light: string;
22
+ dark: string;
23
+ };
24
+ launcherIconImageSrc: string;
25
+ emptyStateHeading?: string;
26
+ emptyStateQuestions?: string[];
27
+ devProxyToken?: string;
28
+ chrome: AssistantChromeConfig;
29
+ };
30
+
31
+ export function getAssistantPanelRuntimeConfig(
32
+ config: DocsConfig,
33
+ ): AssistantPanelRuntimeConfig {
34
+ const assistantLauncherIcon = getAssistantLauncherIconConfig(config);
35
+ const assistantConfig = config.assistant;
36
+ const parsedOrgTier = Number.parseInt(
37
+ (import.meta.env.ORG_TIER ?? "1").toString(),
38
+ 10,
39
+ );
40
+ const orgTier =
41
+ Number.isFinite(parsedOrgTier) && parsedOrgTier > 0 ? parsedOrgTier : 1;
42
+ const isDev = import.meta.env.DEV;
43
+ const assistantDevHost = (import.meta.env.ASK_AI_DEV_HOST ?? "")
44
+ .toString()
45
+ .trim();
46
+ const assistantDevProxySecret = (
47
+ import.meta.env.ASK_AI_DEV_PROXY_SECRET ?? ""
48
+ )
49
+ .toString()
50
+ .trim();
51
+ const hasAssistantDevConfig =
52
+ assistantDevHost.length > 0 && assistantDevProxySecret.length > 0;
53
+ const isChatAvailable = isDev || orgTier >= 3;
54
+ const canSendChatRequest = isDev ? hasAssistantDevConfig : orgTier >= 3;
55
+ let apiPath = withBasePath("/_platform/assistant");
56
+
57
+ if (isDev && hasAssistantDevConfig) {
58
+ try {
59
+ apiPath = new URL("/_platform/assistant", assistantDevHost).toString();
60
+ } catch {
61
+ apiPath = withBasePath("/_platform/assistant");
62
+ }
63
+ }
64
+
65
+ return {
66
+ apiPath,
67
+ docsTitle: config.title,
68
+ isChatAvailable,
69
+ canSendChatRequest,
70
+ launcherThemeColor: assistantLauncherIcon.themeColor,
71
+ launcherThemeColors: assistantLauncherIcon.themeColors,
72
+ launcherIconColor: assistantLauncherIcon.color,
73
+ launcherIconColors: assistantLauncherIcon.colors,
74
+ launcherIconImageSrc: assistantLauncherIcon.imageSrc,
75
+ emptyStateHeading: assistantConfig?.heading,
76
+ emptyStateQuestions: assistantConfig?.questions,
77
+ devProxyToken: isDev ? assistantDevProxySecret : undefined,
78
+ chrome: getAssistantChromeConfig(config),
79
+ };
80
+ }
@@ -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>,
@@ -0,0 +1,31 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export type FaviconConfig = {
5
+ href: string;
6
+ type: string;
7
+ };
8
+
9
+ const DOCS_DIR = path.join(process.cwd(), "src/content/docs");
10
+ const DEFAULT_FAVICON: FaviconConfig = {
11
+ href: "/favicon.svg",
12
+ type: "image/svg+xml",
13
+ };
14
+
15
+ const FAVICON_CANDIDATES: FaviconConfig[] = [
16
+ DEFAULT_FAVICON,
17
+ { href: "/favicon.ico", type: "image/x-icon" },
18
+ { href: "/favicon.png", type: "image/png" },
19
+ { href: "/favicon.webp", type: "image/webp" },
20
+ { href: "/favicon.avif", type: "image/avif" },
21
+ { href: "/favicon.jpg", type: "image/jpeg" },
22
+ { href: "/favicon.jpeg", type: "image/jpeg" },
23
+ ];
24
+
25
+ export function getFaviconConfig(): FaviconConfig {
26
+ const favicon = FAVICON_CANDIDATES.find((candidate) =>
27
+ fs.existsSync(path.join(DOCS_DIR, candidate.href.replace(/^\/+/, ""))),
28
+ );
29
+
30
+ return favicon ?? DEFAULT_FAVICON;
31
+ }
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import type { Link, Root } from "mdast";
3
4
  import type { Plugin } from "unified";
@@ -6,15 +7,17 @@ import {
6
7
  getConfig,
7
8
  loadOpenApiSpec,
8
9
  type DocsConfig,
10
+ type HiddenPageRoute,
9
11
  type NavGroup,
10
12
  type NavMenuItem,
11
13
  type NavOpenApi,
12
14
  type NavOpenApiPage,
13
15
  type NavPage,
14
16
  } from "../validation";
17
+ import { prependBasePath, withBasePath } from "../base-path";
15
18
  import {
16
19
  buildMdxPageHref,
17
- buildOpenApiEndpointSlug,
20
+ buildOpenApiEndpointHref,
18
21
  parseOpenApiEndpoint,
19
22
  slugify,
20
23
  } from "../utils";
@@ -25,6 +28,7 @@ type ResolvedRouteIndex = {
25
28
  canonicalHrefByFilePath: Map<string, string>;
26
29
  allHrefsByFilePath: Map<string, string[]>;
27
30
  validRoutePaths: Set<string>;
31
+ allDocsFilePaths: Set<string>;
28
32
  };
29
33
 
30
34
  type LinkSplit = {
@@ -145,6 +149,38 @@ function getCurrentDocFilePath(filePath: string | undefined): string | null {
145
149
  return normalizeDocsFilePath(relativePath);
146
150
  }
147
151
 
152
+ function collectDocsFilePaths(directory: string): string[] {
153
+ let entries: fs.Dirent[];
154
+ try {
155
+ entries = fs.readdirSync(directory, { withFileTypes: true });
156
+ } catch {
157
+ return [];
158
+ }
159
+
160
+ const results: string[] = [];
161
+ for (const entry of entries) {
162
+ const entryPath = path.join(directory, entry.name);
163
+ if (entry.name.startsWith(".") || entry.name === "node_modules") {
164
+ continue;
165
+ }
166
+
167
+ if (entry.isDirectory()) {
168
+ results.push(...collectDocsFilePaths(entryPath));
169
+ continue;
170
+ }
171
+
172
+ if (entry.isFile() && /\.(md|mdx)$/i.test(entry.name)) {
173
+ const relativePath = path.relative(DOCS_ROOT, entryPath);
174
+ const normalizedFilePath = normalizeDocsFilePath(relativePath);
175
+ if (normalizedFilePath) {
176
+ results.push(normalizedFilePath);
177
+ }
178
+ }
179
+ }
180
+
181
+ return results;
182
+ }
183
+
148
184
  function buildFilePathCandidates(args: {
149
185
  baseHref: string;
150
186
  currentDocFilePath: string | null;
@@ -235,11 +271,11 @@ function addMdxRoute(args: {
235
271
  }
236
272
 
237
273
  if (args.homePath && normalizedFilePath === args.homePath) {
238
- addValidRoutePath(args.index, "/");
239
- if (!aliases.includes("/")) {
240
- aliases.unshift("/");
274
+ const homeHref = addValidRoutePath(args.index, prependBasePath("/"));
275
+ if (!aliases.includes(homeHref)) {
276
+ aliases.unshift(homeHref);
241
277
  }
242
- args.index.canonicalHrefByFilePath.set(normalizedFilePath, "/");
278
+ args.index.canonicalHrefByFilePath.set(normalizedFilePath, homeHref);
243
279
  }
244
280
 
245
281
  args.index.allHrefsByFilePath.set(normalizedFilePath, aliases);
@@ -251,11 +287,33 @@ function addOpenApiEndpointRoute(args: {
251
287
  method: string;
252
288
  endpointPath: string;
253
289
  }): void {
254
- const endpointSlug = buildOpenApiEndpointSlug(args.endpointPath, args.method);
255
- const fullSlug = args.parentSlug
256
- ? `${args.parentSlug}/${endpointSlug}`
257
- : endpointSlug;
258
- addValidRoutePath(args.index, fullSlug);
290
+ addValidRoutePath(
291
+ args.index,
292
+ buildOpenApiEndpointHref({
293
+ path: args.endpointPath,
294
+ method: args.method,
295
+ groupSlug: args.parentSlug,
296
+ }),
297
+ );
298
+ }
299
+
300
+ function addHiddenPageRoute(
301
+ index: ResolvedRouteIndex,
302
+ route: HiddenPageRoute,
303
+ ): void {
304
+ const normalizedFilePath = normalizeDocsFilePath(route.filePath);
305
+ if (!normalizedFilePath) return;
306
+
307
+ const href = addValidRoutePath(index, prependBasePath(route.href));
308
+ if (!index.canonicalHrefByFilePath.has(normalizedFilePath)) {
309
+ index.canonicalHrefByFilePath.set(normalizedFilePath, href);
310
+ }
311
+
312
+ const aliases = index.allHrefsByFilePath.get(normalizedFilePath) ?? [];
313
+ if (!aliases.includes(href)) {
314
+ aliases.push(href);
315
+ }
316
+ index.allHrefsByFilePath.set(normalizedFilePath, aliases);
259
317
  }
260
318
 
261
319
  function shouldIncludeEndpoint(
@@ -431,13 +489,20 @@ async function processMenuItems(args: {
431
489
  }
432
490
  }
433
491
 
434
- async function buildRouteIndex(config: DocsConfig): Promise<ResolvedRouteIndex> {
492
+ async function buildRouteIndex(
493
+ config: DocsConfig,
494
+ ): Promise<ResolvedRouteIndex> {
435
495
  const index: ResolvedRouteIndex = {
436
496
  canonicalHrefByFilePath: new Map<string, string>(),
437
497
  allHrefsByFilePath: new Map<string, string[]>(),
438
498
  validRoutePaths: new Set<string>(),
499
+ allDocsFilePaths: new Set<string>(),
439
500
  };
440
501
 
502
+ for (const docFilePath of collectDocsFilePaths(DOCS_ROOT)) {
503
+ index.allDocsFilePaths.add(docFilePath);
504
+ }
505
+
441
506
  const homePath =
442
507
  typeof config.home === "string" ? normalizeDocsFilePath(config.home) : null;
443
508
 
@@ -459,7 +524,10 @@ async function buildRouteIndex(config: DocsConfig): Promise<ResolvedRouteIndex>
459
524
  }
460
525
 
461
526
  const rootOpenApi = (config.navigation as any).openapi;
462
- if (typeof rootOpenApi === "string" || (rootOpenApi && typeof rootOpenApi === "object")) {
527
+ if (
528
+ typeof rootOpenApi === "string" ||
529
+ (rootOpenApi && typeof rootOpenApi === "object")
530
+ ) {
463
531
  await processOpenApiFile({
464
532
  index,
465
533
  parentSlug: "",
@@ -467,7 +535,10 @@ async function buildRouteIndex(config: DocsConfig): Promise<ResolvedRouteIndex>
467
535
  });
468
536
  } else if (Array.isArray(rootOpenApi)) {
469
537
  for (const openApiItem of rootOpenApi) {
470
- if (typeof openApiItem !== "string" && (!openApiItem || typeof openApiItem !== "object")) {
538
+ if (
539
+ typeof openApiItem !== "string" &&
540
+ (!openApiItem || typeof openApiItem !== "object")
541
+ ) {
471
542
  continue;
472
543
  }
473
544
 
@@ -480,7 +551,11 @@ async function buildRouteIndex(config: DocsConfig): Promise<ResolvedRouteIndex>
480
551
  }
481
552
 
482
553
  if (homePath) {
483
- addValidRoutePath(index, "/");
554
+ addValidRoutePath(index, prependBasePath("/"));
555
+ }
556
+
557
+ for (const hiddenPageRoute of config.hiddenPageRoutes ?? []) {
558
+ addHiddenPageRoute(index, hiddenPageRoute);
484
559
  }
485
560
 
486
561
  return index;
@@ -501,11 +576,24 @@ function buildInvalidInternalLinkError(args: {
501
576
  baseHref: string;
502
577
  currentDocFilePath: string | null;
503
578
  filePathCandidates: string[];
579
+ existingFilePathCandidates: string[];
504
580
  }): Error {
505
581
  const sourceFile = args.currentDocFilePath
506
582
  ? `${args.currentDocFilePath}.mdx`
507
583
  : "the current MDX file";
508
584
 
585
+ if (args.existingFilePathCandidates.length > 0) {
586
+ const existingFiles = args.existingFilePathCandidates
587
+ .map((candidate) => `"${candidate}.mdx"`)
588
+ .join(", ");
589
+
590
+ return new Error(
591
+ `[USER_ERROR]: Invalid internal link "${args.baseHref}" in ${sourceFile}. ` +
592
+ `The target file exists (${existingFiles}), but it is not a routable docs page. ` +
593
+ `Add it to docs.json navigation, set it as the home page, or link to it from the navbar or footer so it is included in the built site.`,
594
+ );
595
+ }
596
+
509
597
  const candidateHint =
510
598
  args.filePathCandidates.length > 0
511
599
  ? ` Tried file path candidates: ${args.filePathCandidates
@@ -515,7 +603,7 @@ function buildInvalidInternalLinkError(args: {
515
603
 
516
604
  return new Error(
517
605
  `[USER_ERROR]: Invalid internal link "${args.baseHref}" in ${sourceFile}. ` +
518
- `Link must match an existing docs URL or resolve to a docs page file in navigation.${candidateHint}`,
606
+ `No matching docs page file was found. Create the target .mdx file or update the link.${candidateHint}`,
519
607
  );
520
608
  }
521
609
 
@@ -532,12 +620,29 @@ function resolveLinkToCanonicalHref(args: {
532
620
  }
533
621
 
534
622
  const normalizedBaseRoute = normalizeRoutePath(baseHref);
623
+ const normalizedBaseRouteWithBase = normalizeRoutePath(
624
+ baseHref.startsWith("/")
625
+ ? withBasePath(normalizedBaseRoute)
626
+ : prependBasePath(normalizedBaseRoute),
627
+ );
628
+ if (args.routeIndex.validRoutePaths.has(normalizedBaseRouteWithBase)) {
629
+ if (
630
+ baseHref.startsWith("/") &&
631
+ normalizedBaseRoute === normalizedBaseRouteWithBase
632
+ ) {
633
+ return null;
634
+ }
635
+
636
+ return `${normalizedBaseRouteWithBase}${parts.suffix}`;
637
+ }
638
+
535
639
  if (args.routeIndex.validRoutePaths.has(normalizedBaseRoute)) {
536
- if (baseHref.startsWith("/")) {
640
+ const publicRoute = normalizeRoutePath(withBasePath(normalizedBaseRoute));
641
+ if (baseHref.startsWith("/") && normalizedBaseRoute === publicRoute) {
537
642
  return null;
538
643
  }
539
644
 
540
- return `${normalizedBaseRoute}${parts.suffix}`;
645
+ return `${publicRoute}${parts.suffix}`;
541
646
  }
542
647
 
543
648
  const filePathCandidates = buildFilePathCandidates({
@@ -556,14 +661,19 @@ function resolveLinkToCanonicalHref(args: {
556
661
  );
557
662
  }
558
663
 
559
- return `${canonicalHref}${parts.suffix}`;
664
+ return `${withBasePath(canonicalHref)}${parts.suffix}`;
560
665
  }
561
666
 
562
667
  if (shouldEnforceInternalRouteValidation(baseHref)) {
668
+ const existingFilePathCandidates = filePathCandidates.filter((filePath) =>
669
+ args.routeIndex.allDocsFilePaths.has(filePath),
670
+ );
671
+
563
672
  throw buildInvalidInternalLinkError({
564
673
  baseHref,
565
674
  currentDocFilePath: args.currentDocFilePath,
566
675
  filePathCandidates,
676
+ existingFilePathCandidates,
567
677
  });
568
678
  }
569
679
 
@@ -1,4 +1,5 @@
1
1
  // Pagefind TypeScript types and wrapper
2
+ import { getDocsBasePath, withBasePath } from "./base-path";
2
3
  import { resolveStaticAssetUrl } from "./static-asset-url";
3
4
 
4
5
  export interface PagefindSearchResult {
@@ -38,11 +39,11 @@ export interface PagefindSearchResponse {
38
39
  }
39
40
 
40
41
  export interface PagefindInstance {
41
- options?: (options: { baseUrl?: string; basePath?: string }) => Promise<void>;
42
+ options?: (options: { baseUrl?: string }) => Promise<void>;
42
43
  init: () => Promise<void>;
43
44
  search: (
44
45
  query: string,
45
- options?: { filters?: Record<string, string> }
46
+ options?: { filters?: Record<string, string> },
46
47
  ) => Promise<PagefindSearchResponse>;
47
48
  filters: () => Promise<Record<string, Record<string, number>>>;
48
49
  preload: (query: string) => Promise<void>;
@@ -54,6 +55,57 @@ function getPagefindScriptUrl(): string {
54
55
  return resolveStaticAssetUrl("/pagefind/pagefind.js");
55
56
  }
56
57
 
58
+ function getPagefindResultBaseUrl(): string {
59
+ if (typeof window === "undefined") return "/";
60
+
61
+ return `${window.location.origin}${getDocsBasePath()}`;
62
+ }
63
+
64
+ function withBasePathForSearchResultUrl(url: string): string {
65
+ const basePath = getDocsBasePath();
66
+ if (!basePath || typeof window === "undefined") {
67
+ return withBasePath(url);
68
+ }
69
+
70
+ const isAbsoluteUrl =
71
+ /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url) || url.startsWith("//");
72
+ if (!isAbsoluteUrl) {
73
+ return withBasePath(url);
74
+ }
75
+
76
+ try {
77
+ const parsed = new URL(url, window.location.href);
78
+ if (
79
+ parsed.origin === window.location.origin &&
80
+ parsed.pathname !== basePath &&
81
+ !parsed.pathname.startsWith(`${basePath}/`)
82
+ ) {
83
+ const pathname = parsed.pathname.startsWith("/")
84
+ ? parsed.pathname
85
+ : `/${parsed.pathname}`;
86
+ parsed.pathname = `${basePath}${pathname}`;
87
+ return parsed.toString();
88
+ }
89
+ } catch {
90
+ // Fall through to normal relative URL handling.
91
+ }
92
+
93
+ return withBasePath(url);
94
+ }
95
+
96
+ function withBasePathForSearchResult(
97
+ data: PagefindResultData,
98
+ ): PagefindResultData {
99
+ return {
100
+ ...data,
101
+ url: withBasePathForSearchResultUrl(data.url),
102
+ sub_results: data.sub_results?.map((subResult) => ({
103
+ ...subResult,
104
+ url: withBasePathForSearchResultUrl(subResult.url),
105
+ })),
106
+ };
107
+ }
108
+
57
109
  export async function getPagefind(): Promise<PagefindInstance | null> {
58
110
  if (pagefindInstance) return pagefindInstance;
59
111
 
@@ -62,17 +114,15 @@ export async function getPagefind(): Promise<PagefindInstance | null> {
62
114
  // This keeps Pagefind loading purely runtime and allows versioned query params.
63
115
  const importPagefind = new Function(
64
116
  "moduleUrl",
65
- "return import(moduleUrl)"
117
+ "return import(moduleUrl)",
66
118
  ) as (moduleUrl: string) => Promise<PagefindInstance>;
67
119
 
68
120
  const pagefind = await importPagefind(getPagefindScriptUrl());
69
- // Pagefind uses the script import location to derive result URL base.
70
- // Since we import from the static host, force result links back to the
71
- // current docs site origin while leaving asset fetches on static host.
121
+ // Pagefind uses the pagefind.js import URL to locate its index files.
122
+ // Only override result URLs; passing Pagefind's basePath would move
123
+ // pagefind-entry.json and chunk fetches back onto the docs origin.
72
124
  if (typeof pagefind.options === "function") {
73
- const baseUrl =
74
- typeof window !== "undefined" ? window.location.origin : "/";
75
- await pagefind.options({ baseUrl });
125
+ await pagefind.options({ baseUrl: getPagefindResultBaseUrl() });
76
126
  }
77
127
  await pagefind.init();
78
128
  pagefindInstance = pagefind;
@@ -85,7 +135,7 @@ export async function getPagefind(): Promise<PagefindInstance | null> {
85
135
 
86
136
  export async function search(
87
137
  query: string,
88
- limit: number = 8
138
+ limit: number = 8,
89
139
  ): Promise<PagefindResultData[]> {
90
140
  const pagefind = await getPagefind();
91
141
  if (!pagefind || !query.trim()) return [];
@@ -94,10 +144,8 @@ export async function search(
94
144
 
95
145
  // Load full data for top results
96
146
  const results = await Promise.all(
97
- response.results.slice(0, limit).map((result) => result.data())
147
+ response.results.slice(0, limit).map((result) => result.data()),
98
148
  );
99
149
 
100
- console.log("results", results);
101
-
102
- return results;
150
+ return results.map(withBasePathForSearchResult);
103
151
  }