jamdesk 1.1.75 → 1.1.77

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 (61) hide show
  1. package/dist/__tests__/unit/deps.test.js +184 -0
  2. package/dist/__tests__/unit/deps.test.js.map +1 -1
  3. package/dist/__tests__/unit/dev-spinner-ownership.test.d.ts +2 -0
  4. package/dist/__tests__/unit/dev-spinner-ownership.test.d.ts.map +1 -0
  5. package/dist/__tests__/unit/dev-spinner-ownership.test.js +37 -0
  6. package/dist/__tests__/unit/dev-spinner-ownership.test.js.map +1 -0
  7. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +2 -0
  8. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +1 -0
  9. package/dist/__tests__/unit/dev-workspace-symlinks.test.js +112 -0
  10. package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +1 -0
  11. package/dist/__tests__/unit/language-filter.test.d.ts +2 -0
  12. package/dist/__tests__/unit/language-filter.test.d.ts.map +1 -0
  13. package/dist/__tests__/unit/language-filter.test.js +166 -0
  14. package/dist/__tests__/unit/language-filter.test.js.map +1 -0
  15. package/dist/__tests__/unit/output.test.d.ts +2 -0
  16. package/dist/__tests__/unit/output.test.d.ts.map +1 -0
  17. package/dist/__tests__/unit/output.test.js +61 -0
  18. package/dist/__tests__/unit/output.test.js.map +1 -0
  19. package/dist/__tests__/unit/spinner.test.d.ts +2 -0
  20. package/dist/__tests__/unit/spinner.test.d.ts.map +1 -0
  21. package/dist/__tests__/unit/spinner.test.js +83 -0
  22. package/dist/__tests__/unit/spinner.test.js.map +1 -0
  23. package/dist/commands/dev.d.ts.map +1 -1
  24. package/dist/commands/dev.js +13 -3
  25. package/dist/commands/dev.js.map +1 -1
  26. package/dist/lib/deps.d.ts +22 -0
  27. package/dist/lib/deps.d.ts.map +1 -1
  28. package/dist/lib/deps.js +121 -27
  29. package/dist/lib/deps.js.map +1 -1
  30. package/dist/lib/language-filter.d.ts +31 -0
  31. package/dist/lib/language-filter.d.ts.map +1 -0
  32. package/dist/lib/language-filter.js +14 -0
  33. package/dist/lib/language-filter.js.map +1 -0
  34. package/dist/lib/spinner.d.ts +24 -0
  35. package/dist/lib/spinner.d.ts.map +1 -1
  36. package/dist/lib/spinner.js +59 -0
  37. package/dist/lib/spinner.js.map +1 -1
  38. package/package.json +3 -3
  39. package/vendored/app/[[...slug]]/page.tsx +12 -4
  40. package/vendored/app/layout.tsx +25 -10
  41. package/vendored/components/mdx/ApiPage.tsx +10 -2
  42. package/vendored/components/mdx/OpenApiEndpoint.tsx +41 -44
  43. package/vendored/components/mdx/YouTube.tsx +8 -0
  44. package/vendored/components/navigation/Sidebar.tsx +32 -17
  45. package/vendored/components/navigation/TabsNav.tsx +22 -30
  46. package/vendored/components/ui/CodePanel.tsx +48 -3
  47. package/vendored/hooks/useIsNavigationSettled.ts +74 -0
  48. package/vendored/lib/layout-helpers.tsx +27 -0
  49. package/vendored/lib/middleware-helpers.ts +79 -8
  50. package/vendored/lib/page-isr-helpers.ts +14 -9
  51. package/vendored/lib/prefetch-batcher.ts +51 -0
  52. package/vendored/lib/prefetch-rsc.ts +19 -0
  53. package/vendored/lib/project-resolver.ts +21 -5
  54. package/vendored/lib/r2-content.ts +16 -0
  55. package/vendored/lib/r2-feature-flags.ts +7 -0
  56. package/vendored/lib/render-doc-page-openapi-helpers.ts +110 -0
  57. package/vendored/lib/render-doc-page-parallel-helpers.ts +60 -0
  58. package/vendored/lib/render-doc-page.tsx +101 -52
  59. package/vendored/lib/sidebar-prefetch-walker.ts +50 -0
  60. package/vendored/lib/static-artifacts.ts +2 -1
  61. package/vendored/workspace-package-lock.json +101 -99
@@ -58,8 +58,23 @@ export interface ProjectResolutionResult {
58
58
  * (i.e., a user's own domain pointing at our CNAME), vs via the
59
59
  * subdomain branch (slug.jamdesk.app). Used by the branded inactive
60
60
  * page to show owner-sign-in hints only to real customers.
61
+ *
62
+ * Renamed from `customDomain` to disambiguate from the new
63
+ * `customDomain?: string` mirror below (which carries the public
64
+ * canonical hostname read from `projectCfg`). The two fields answer
65
+ * different questions:
66
+ * - `viaCustomDomain` = "did this request hit the domain: Redis key?"
67
+ * - `customDomain` = "what is the project's registered public face?"
61
68
  */
62
- customDomain?: boolean;
69
+ viaCustomDomain?: boolean;
70
+ /**
71
+ * Public canonical hostname for this slug, mirrored from `projectCfg`.
72
+ * Set only when the project owner has registered + activated a custom
73
+ * domain. Used by the proxy to override the canonical URL when serving
74
+ * a hostAtDocs project directly via *.jamdesk.app (avoiding duplicate-
75
+ * URL indexing — the upstream subdomain is not the public face).
76
+ */
77
+ customDomain?: string;
63
78
  }
64
79
 
65
80
  /**
@@ -124,7 +139,11 @@ export async function handleProjectResolution(
124
139
  slug,
125
140
  });
126
141
  const config = await getProjectConfig(slug);
127
- return { projectSlug: slug, hostAtDocs: config.hostAtDocs };
142
+ return {
143
+ projectSlug: slug,
144
+ hostAtDocs: config.hostAtDocs,
145
+ customDomain: config.customDomain,
146
+ };
128
147
  }
129
148
 
130
149
  log('info', 'Resolving project from hostname', { hostname });
@@ -166,8 +185,18 @@ export async function handleProjectResolution(
166
185
  getProjectConfig(projectSlug),
167
186
  getProjectInactive(projectSlug),
168
187
  ]);
169
- log('info', 'Project resolved from subdomain', { hostname, projectSlug, inactive });
170
- return { projectSlug, hostAtDocs: config.hostAtDocs, inactive };
188
+ log('info', 'Project resolved from subdomain', {
189
+ hostname,
190
+ projectSlug,
191
+ inactive,
192
+ customDomain: config.customDomain,
193
+ });
194
+ return {
195
+ projectSlug,
196
+ hostAtDocs: config.hostAtDocs,
197
+ inactive,
198
+ customDomain: config.customDomain,
199
+ };
171
200
  }
172
201
  }
173
202
 
@@ -204,7 +233,7 @@ export async function handleProjectResolution(
204
233
  hostAtDocs: resolution.hostAtDocs,
205
234
  domainStatus: resolution.domainStatus,
206
235
  inactive,
207
- customDomain: true,
236
+ viaCustomDomain: true,
208
237
  };
209
238
  }
210
239
 
@@ -596,15 +625,48 @@ const TRUSTED_PROXY_HEADERS = [
596
625
  'x-jd-custom-domain',
597
626
  'x-jd-project-name',
598
627
  'x-jd-project-logo',
628
+ // Canonical override: middleware writes this when serving *.jamdesk.app
629
+ // directly for a hostAtDocs project that has a registered custom
630
+ // domain. The page render path uses it to emit the public-face canonical
631
+ // instead of the upstream subdomain URL.
632
+ 'x-jd-canonical-host',
633
+ // Set when *.jamdesk.app serves a hostAtDocs project that has NOT
634
+ // registered a custom domain yet — page emits robots: noindex so the
635
+ // upstream URL doesn't compete with the (yet-to-arrive) public face
636
+ // in search results.
637
+ 'x-jd-noindex',
599
638
  ] as const;
600
639
 
640
+ /**
641
+ * Optional extras for `buildProjectHeaders`. All fields are middleware-
642
+ * authored; the corresponding header is in `TRUSTED_PROXY_HEADERS` so an
643
+ * inbound spoof is stripped before we set our own value.
644
+ */
645
+ export interface BuildProjectHeadersOptions {
646
+ /**
647
+ * Public-face canonical host. Set when serving *.jamdesk.app directly
648
+ * for a hostAtDocs project that has a registered custom domain — the
649
+ * page render path emits this host in <link rel="canonical"> instead
650
+ * of the upstream subdomain.
651
+ */
652
+ canonicalHost?: string;
653
+ /**
654
+ * When true, emit `x-jd-noindex: true` so the page emits a robots
655
+ * noindex tag. Used for *.jamdesk.app subdomains of hostAtDocs
656
+ * projects without a custom domain yet.
657
+ */
658
+ noindex?: boolean;
659
+ }
660
+
601
661
  /**
602
662
  * Build response headers with project context.
603
663
  *
604
664
  * Sets:
605
- * - x-project-slug — resolved project for downstream handlers
606
- * - x-host-at-docs — whether docs are mounted at /docs
607
- * - x-jd-language — locale code if the path starts with one (e.g. /fr/...)
665
+ * - x-project-slug — resolved project for downstream handlers
666
+ * - x-host-at-docs — whether docs are mounted at /docs
667
+ * - x-jd-language — locale code if the path starts with one (e.g. /fr/...)
668
+ * - x-jd-canonical-host — public-face host (opts.canonicalHost)
669
+ * - x-jd-noindex — "true" when opts.noindex is set
608
670
  *
609
671
  * Strips any client-supplied copies of those headers from the inbound
610
672
  * request to prevent header smuggling.
@@ -615,6 +677,7 @@ const TRUSTED_PROXY_HEADERS = [
615
677
  * @param pathname - The request pathname (request.nextUrl.pathname or, for
616
678
  * the unlock direct-hit branch, the `from` query param). Used to derive
617
679
  * the active locale so the root layout can emit <html lang>.
680
+ * @param opts - Optional extras (canonical host override, noindex flag).
618
681
  * @returns New headers with project context added
619
682
  */
620
683
  export function buildProjectHeaders(
@@ -622,6 +685,7 @@ export function buildProjectHeaders(
622
685
  existingHeaders: Headers,
623
686
  hostAtDocs: boolean,
624
687
  pathname: string,
688
+ opts: BuildProjectHeadersOptions = {},
625
689
  ): Headers {
626
690
  const newHeaders = new Headers(existingHeaders);
627
691
  for (const h of TRUSTED_PROXY_HEADERS) {
@@ -636,6 +700,13 @@ export function buildProjectHeaders(
636
700
  newHeaders.set('x-jd-language', language);
637
701
  }
638
702
 
703
+ if (opts.canonicalHost) {
704
+ newHeaders.set('x-jd-canonical-host', opts.canonicalHost);
705
+ }
706
+ if (opts.noindex) {
707
+ newHeaders.set('x-jd-noindex', 'true');
708
+ }
709
+
639
710
  return newHeaders;
640
711
  }
641
712
 
@@ -130,15 +130,20 @@ export function parseCacheKey(cacheKey: string): { projectSlug: string; pagePath
130
130
  /**
131
131
  * Get base URL for SEO metadata in ISR mode.
132
132
  *
133
- * Derives the canonical base URL from request headers.
134
- * Prefers x-jamdesk-forwarded-host (set by Cloudflare Worker proxy) over the
135
- * host header, so that canonical URLs point to the user-facing domain
136
- * (e.g., jamdesk.com) rather than the ISR subdomain (jamdesk-docs.jamdesk.app).
133
+ * Header priority (highest first):
134
+ * 1. x-jd-canonical-host internal override set by middleware when a
135
+ * hostAtDocs project is served directly via *.jamdesk.app and has a
136
+ * registered customDomain. Forces the canonical to the public face.
137
+ * 2. x-jamdesk-forwarded-host — set by the Cloudflare Worker proxy
138
+ * (e.g. jamdesk.com → forwarded `jamdesk.com`).
139
+ * 3. host header — direct request hostname.
140
+ * 4. Subdomain fallback `<slug>.jamdesk.app` (no headers available).
137
141
  *
138
- * Security: The forwarded host header is validated by middleware
139
- * (validateForwardedHost in middleware-helpers.ts) before the page component runs.
142
+ * Both override headers are in TRUSTED_PROXY_HEADERS so a client can't
143
+ * spoof them.
140
144
  *
141
- * When hostAtDocs=true, includes /docs path prefix to match the actual URL structure.
145
+ * When hostAtDocs=true, includes /docs path prefix (forwarded-host and
146
+ * canonical-host paths only; subdomain fallback always serves at root).
142
147
  *
143
148
  * @param headers - Request headers object
144
149
  * @param projectSlug - Project identifier (fallback for subdomain URL)
@@ -146,9 +151,9 @@ export function parseCacheKey(cacheKey: string): { projectSlug: string; pagePath
146
151
  * @returns Base URL (e.g., 'https://jamdesk.com/docs' or 'https://acme.jamdesk.app')
147
152
  */
148
153
  export function getBaseUrl(headers: Headers, projectSlug: string, hostAtDocs = false): string {
149
- // Prefer forwarded host (from Cloudflare Worker proxy) for correct canonical URLs
154
+ const canonicalHost = headers.get('x-jd-canonical-host');
150
155
  const forwardedHost = headers.get('x-jamdesk-forwarded-host');
151
- const host = forwardedHost || headers.get('host');
156
+ const host = canonicalHost || forwardedHost || headers.get('host');
152
157
 
153
158
  if (host) {
154
159
  const hostname = host.split(':')[0];
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Client-side prefetch batcher for sidebar links.
3
+ *
4
+ * Fires up to PARALLEL_LIMIT prefetches in parallel synchronously. The
5
+ * caller is expected to gate calls on "the user's current navigation
6
+ * has settled" so the batch never races the click that triggered it.
7
+ *
8
+ * `seen` is module-lifetime (cleared only on hard navigation/reload).
9
+ * For a 5,000-page docs site this is ~5,000 short strings ≈ tens of KB.
10
+ *
11
+ * In-flight fetches are not cancellable. The bound is the safety story:
12
+ * at most PARALLEL_LIMIT orphaned fetches outlive a re-click.
13
+ */
14
+
15
+ export const PARALLEL_LIMIT = 5;
16
+
17
+ const seen = new Set<string>();
18
+
19
+ function safeInvoke(cb: (href: string) => void, href: string): void {
20
+ try {
21
+ cb(href);
22
+ } catch (err) {
23
+ if (process.env.NODE_ENV !== 'production') {
24
+ console.warn('[prefetch-batcher] callback threw for', href, err);
25
+ }
26
+ }
27
+ }
28
+
29
+ export const prefetchBatcher = {
30
+ fire(items: string[], cb: (href: string) => void, limit = PARALLEL_LIMIT): void {
31
+ // SSR guard: module resolves on the server during initial render of
32
+ // client components. seen would leak across requests.
33
+ if (typeof window === 'undefined') return;
34
+ if (items.length === 0) return;
35
+
36
+ const batch = items.slice(0, limit);
37
+ // Mark seen BEFORE invoking — a thrown callback retires the href
38
+ // permanently. Intentional: a route that throws once likely throws
39
+ // again, and prefetches are best-effort. Hard reload clears `seen`.
40
+ for (const href of batch) seen.add(href);
41
+ for (const href of batch) safeInvoke(cb, href);
42
+ },
43
+
44
+ hasSeen(href: string): boolean {
45
+ return seen.has(href);
46
+ },
47
+
48
+ resetForTesting(): void {
49
+ seen.clear();
50
+ },
51
+ };
@@ -0,0 +1,19 @@
1
+ // Issues an RSC GET (without `Next-Router-Prefetch`) so the route's full
2
+ // server render runs and the regional Vercel Data Cache is populated.
3
+
4
+ export function prefetchRsc(href: string): void {
5
+ if (typeof window === 'undefined') return;
6
+ const sep = href.includes('?') ? '&' : '?';
7
+ const url = `${href}${sep}_rsc=${Date.now().toString(36)}`;
8
+ fetch(url, {
9
+ method: 'GET',
10
+ headers: { RSC: '1', Accept: 'text/x-component' },
11
+ cache: 'no-store',
12
+ credentials: 'same-origin',
13
+ priority: 'low',
14
+ })
15
+ // Consume the body so the response buffer is released. Letting it
16
+ // sit unread holds ~100KB per paced fetch in memory until GC.
17
+ .then((r) => r.arrayBuffer())
18
+ .catch(() => {});
19
+ }
@@ -97,6 +97,17 @@ export async function resolveCustomDomain(hostname: string): Promise<DomainResol
97
97
  }
98
98
  }
99
99
 
100
+ /**
101
+ * Resolved project configuration mirrored from Redis `projectCfg:<slug>`.
102
+ *
103
+ * `customDomain` is set on activation by the domain.ts helper when a custom
104
+ * domain is TXT-verified. Used by middleware to emit canonical-host headers.
105
+ */
106
+ export interface ProjectCfg {
107
+ hostAtDocs: boolean;
108
+ customDomain?: string;
109
+ }
110
+
100
111
  /**
101
112
  * Get project configuration for a subdomain (silent-fallback variant).
102
113
  * Used for hostAtDocs setting on *.jamdesk.app domains.
@@ -111,7 +122,7 @@ export async function resolveCustomDomain(hostname: string): Promise<DomainResol
111
122
  * would memoize a wrong `false` for the cache TTL, the exact failure
112
123
  * mode that produced the jamdesk.com/docs/* 404 incident.
113
124
  */
114
- export async function getProjectConfig(projectSlug: string): Promise<{ hostAtDocs: boolean }> {
125
+ export async function getProjectConfig(projectSlug: string): Promise<ProjectCfg> {
115
126
  if (!redis) {
116
127
  return { hostAtDocs: false };
117
128
  }
@@ -119,8 +130,10 @@ export async function getProjectConfig(projectSlug: string): Promise<{ hostAtDoc
119
130
  try {
120
131
  const cfgRaw = await redis.get(`projectCfg:${projectSlug}`);
121
132
  const cfg = parseRedisConfig(cfgRaw);
122
- // Default to false (root path) for subdomains unless explicitly set to true
123
- return { hostAtDocs: cfg?.hostAtDocs === true };
133
+ return {
134
+ hostAtDocs: cfg?.hostAtDocs === true,
135
+ customDomain: typeof cfg?.customDomain === 'string' ? cfg.customDomain : undefined,
136
+ };
124
137
  } catch {
125
138
  return { hostAtDocs: false };
126
139
  }
@@ -136,14 +149,17 @@ export async function getProjectConfig(projectSlug: string): Promise<{ hostAtDoc
136
149
  * A missing `projectCfg:<slug>` key is NOT an error — that's the legitimate
137
150
  * "project hasn't opted into hostAtDocs" case and is safe to cache.
138
151
  */
139
- export async function getProjectConfigStrict(projectSlug: string): Promise<{ hostAtDocs: boolean }> {
152
+ export async function getProjectConfigStrict(projectSlug: string): Promise<ProjectCfg> {
140
153
  if (!redis) {
141
154
  return { hostAtDocs: false };
142
155
  }
143
156
 
144
157
  const cfgRaw = await redis.get(`projectCfg:${projectSlug}`);
145
158
  const cfg = parseRedisConfig(cfgRaw);
146
- return { hostAtDocs: cfg?.hostAtDocs === true };
159
+ return {
160
+ hostAtDocs: cfg?.hostAtDocs === true,
161
+ customDomain: typeof cfg?.customDomain === 'string' ? cfg.customDomain : undefined,
162
+ };
147
163
  }
148
164
 
149
165
  /**
@@ -61,3 +61,19 @@ export async function fetchStaticFile(_projectSlug: string, _filename: string):
61
61
  export async function fetchCustomCss(_projectSlug: string): Promise<string | null> { return null; }
62
62
  export async function fetchCustomJs(_projectSlug: string): Promise<string | null> { return null; }
63
63
  export function setR2Client(_client: unknown): void {}
64
+
65
+ // Stubs for the perf observability + race-fetch additions. CLI dev mode
66
+ // reads from the filesystem so these all no-op.
67
+ export const R2_CLIENT_CONFIG = {} as Record<string, unknown>;
68
+ export const R2_OPENAPI_CLIENT_CONFIG = {} as Record<string, unknown>;
69
+ export type R2TimingPhase =
70
+ | 'layout-metadata'
71
+ | 'layout-render'
72
+ | 'page-metadata'
73
+ | 'page-render';
74
+ export function incrementR2Op(_ms: number): void {}
75
+ export function getR2OpsSnapshot(): { ops: number; ms: number } { return { ops: 0, ms: 0 }; }
76
+ export function withR2OpsContext<T>(fn: () => T): T { return fn(); }
77
+ export function enterR2OpsContextForTest<T>(fn: () => T): T { return fn(); }
78
+ export function emitR2OpsSummary(_phase: R2TimingPhase, _projectSlug?: string | null): void {}
79
+ export async function withTimeout<T>(promise: Promise<T>, _operation: string): Promise<T> { return promise; }
@@ -0,0 +1,7 @@
1
+ // `?.trim()` is defensive against trailing whitespace in Vercel UI / CLI
2
+ // stdin — see ISR_MODE incident in builder/CLAUDE.md. Function (not const)
3
+ // so tests can flip the env var via `vi.stubEnv` without a module reset,
4
+ // matching the existing `isIsrMode()` pattern in page-isr-helpers.ts.
5
+ export function isR2ParallelCounterEnabled(): boolean {
6
+ return process.env.R2_PARALLEL_COUNTER?.trim() === 'true';
7
+ }
@@ -0,0 +1,110 @@
1
+ // Try OpenAPI spec candidates concurrently and return the first by INDEX
2
+ // (not by resolution order) whose parse succeeds. Multilingual docs sites
3
+ // (e.g. 10 languages × 3 spec extensions = 30 candidates) previously paid
4
+ // up to 30 sequential R2 RTTs on a cache miss. We fire all candidate
5
+ // fetches at once but await them in index order, so latency on the common
6
+ // case (primary succeeds) is bounded by the primary fetch — not by the
7
+ // slowest of all candidates as it would be with Promise.allSettled.
8
+ import type { OpenApiEndpointData, parseEndpoint } from '@/lib/openapi';
9
+
10
+ type TryCandidate<T> = (specPath: string) => Promise<{ endpoint: T; specPath: string }>;
11
+
12
+ export type CandidateResult<T> =
13
+ | { kind: 'success'; endpoint: T; specPath: string }
14
+ | { kind: 'failure'; lastError: unknown; specPath: string };
15
+
16
+ type Settled<T> =
17
+ | { ok: true; endpoint: T; specPath: string }
18
+ | { ok: false; error: unknown };
19
+
20
+ export async function tryOpenApiCandidatesInParallel<T = OpenApiEndpointData>(
21
+ candidates: string[],
22
+ tryCandidate: TryCandidate<T>,
23
+ ): Promise<CandidateResult<T>> {
24
+ if (candidates.length === 0) {
25
+ return { kind: 'failure', lastError: new Error('no candidates'), specPath: '' };
26
+ }
27
+
28
+ // Eager catch handlers prevent unhandled-rejection logs when a slow loser
29
+ // rejects after the primary has already won. Loser GETs continue running
30
+ // in the background until R2 responds; `maxAttempts: 1` on the OpenAPI
31
+ // client bounds them to one round-trip each.
32
+ const inflight: Promise<Settled<T>>[] = candidates.map((c) =>
33
+ tryCandidate(c).then(
34
+ ({ endpoint, specPath }) => ({ ok: true as const, endpoint, specPath }),
35
+ (error: unknown) => ({ ok: false as const, error }),
36
+ ),
37
+ );
38
+
39
+ // On all-reject, surface the primary candidate's error so logs are
40
+ // attributed to the intended spec — fallback errors are typically "404".
41
+ let primaryError: unknown | undefined;
42
+ for (let i = 0; i < inflight.length; i++) {
43
+ const r = await inflight[i];
44
+ if (r.ok) {
45
+ return { kind: 'success', endpoint: r.endpoint, specPath: r.specPath };
46
+ }
47
+ if (primaryError === undefined) primaryError = r.error;
48
+ }
49
+
50
+ return {
51
+ kind: 'failure',
52
+ lastError: primaryError ?? new Error('unknown'),
53
+ specPath: candidates[0],
54
+ };
55
+ }
56
+
57
+ interface TryOpenApiSpecOpts {
58
+ projectSlug: string | null;
59
+ isIsr: boolean;
60
+ parsedMethod: string;
61
+ parsedPath: string;
62
+ resolveIsrSpec: ((slug: string, specPath: string) => Promise<unknown>) | null;
63
+ getStaticSpec: ((specPath: string, contentDir: string) => Promise<{ api: unknown }>) | null;
64
+ contentDir: string | null;
65
+ parseEndpointFn: typeof parseEndpoint;
66
+ }
67
+
68
+ export function makeTryOpenApiSpec(opts: TryOpenApiSpecOpts) {
69
+ return async (specPath: string): Promise<{ endpoint: OpenApiEndpointData; specPath: string }> => {
70
+ const useIsr = opts.isIsr && !!opts.projectSlug && !!opts.resolveIsrSpec;
71
+ if (useIsr && opts.resolveIsrSpec && opts.projectSlug) {
72
+ const spec = await opts.resolveIsrSpec(opts.projectSlug, specPath);
73
+ const endpoint = opts.parseEndpointFn(
74
+ spec as Parameters<typeof parseEndpoint>[0],
75
+ opts.parsedMethod as never,
76
+ opts.parsedPath,
77
+ specPath,
78
+ );
79
+ return { endpoint, specPath };
80
+ }
81
+ if (!opts.getStaticSpec || !opts.contentDir) {
82
+ throw new Error('static spec branch requires getStaticSpec + contentDir');
83
+ }
84
+ const { api } = await opts.getStaticSpec(specPath, opts.contentDir);
85
+ const endpoint = opts.parseEndpointFn(
86
+ api as Parameters<typeof parseEndpoint>[0],
87
+ opts.parsedMethod as never,
88
+ opts.parsedPath,
89
+ specPath,
90
+ );
91
+ return { endpoint, specPath };
92
+ };
93
+ }
94
+
95
+ // Aggregated on-call signal for misconfigured projects: replaces the
96
+ // per-failure spam log from the old sequential for-loop. Returns null
97
+ // when there's no fallback (single candidate, or primary won). Format
98
+ // is grep-stable — on-call dashboards key off the "[openapi] primary spec"
99
+ // prefix and the "resolved via fallback" delimiter.
100
+ export function formatFallbackWarning(
101
+ candidates: string[],
102
+ winnerSpecPath: string,
103
+ method: string,
104
+ path: string,
105
+ ): string | null {
106
+ if (candidates.length <= 1) return null;
107
+ const primary = candidates[0];
108
+ if (winnerSpecPath === primary) return null;
109
+ return `[openapi] primary spec "${primary}" did not contain ${method} ${path}; resolved via fallback "${winnerSpecPath}"`;
110
+ }
@@ -0,0 +1,60 @@
1
+ // Snippets (R2 I/O) and inline-component extraction (Babel/remark CPU)
2
+ // have no data dependency once raw + preprocessed MDX are available, so
3
+ // they run as Promise.all. The JS event loop interleaves Babel work with
4
+ // R2 await microtasks, giving wall-clock = max(R2, CPU) instead of sum.
5
+ import type React from 'react';
6
+ import type { ExtractedParam } from './remark-extract-param-fields';
7
+
8
+ // AnyComponent matches the project-wide convention used in
9
+ // process-mdx-with-exports.ts and snippet-loader-isr.ts. ComponentType<unknown>
10
+ // is too strict — MDXComponents includes typed React components like
11
+ // MemoExoticComponent<({ title }: CardProps) => JSX.Element>, which are NOT
12
+ // assignable to ComponentType<unknown> (props variance).
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ type AnyComponent = React.ComponentType<any>;
15
+
16
+ interface ParallelInput {
17
+ isIsr: boolean;
18
+ projectSlug: string | null;
19
+ rawContent: string;
20
+ preprocessedContent: string;
21
+ mdxComponents: Record<string, AnyComponent>;
22
+ snippetComponents: Record<string, AnyComponent>;
23
+ loadSnippets: (
24
+ projectSlug: string,
25
+ rawContent: string,
26
+ builtIns: Record<string, AnyComponent>,
27
+ ) => Promise<Record<string, AnyComponent>>;
28
+ buildAlias: (
29
+ rawContent: string,
30
+ snippetComponents: Record<string, AnyComponent>,
31
+ ) => Record<string, AnyComponent>;
32
+ extractInline: (
33
+ content: string,
34
+ components: Record<string, AnyComponent>,
35
+ ) => Promise<{ inlineComponents: Record<string, AnyComponent>; paramFields: ExtractedParam[] }>;
36
+ }
37
+
38
+ interface ParallelOutput {
39
+ snippetAliases: Record<string, AnyComponent>;
40
+ inlineComponents: Record<string, AnyComponent>;
41
+ paramFields: ExtractedParam[];
42
+ }
43
+
44
+ export async function loadSnippetsAndInlineComponents(
45
+ input: ParallelInput,
46
+ ): Promise<ParallelOutput> {
47
+ const snippetsP = input.isIsr && input.projectSlug
48
+ ? input.loadSnippets(input.projectSlug, input.rawContent, input.mdxComponents)
49
+ : Promise.resolve(input.buildAlias(input.rawContent, input.snippetComponents));
50
+
51
+ const inlineP = input.extractInline(input.preprocessedContent, input.mdxComponents);
52
+
53
+ const [snippetAliases, inline] = await Promise.all([snippetsP, inlineP]);
54
+
55
+ return {
56
+ snippetAliases,
57
+ inlineComponents: inline.inlineComponents,
58
+ paramFields: inline.paramFields,
59
+ };
60
+ }