jamdesk 1.1.75 → 1.1.76

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 (38) hide show
  1. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +2 -0
  2. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +1 -0
  3. package/dist/__tests__/unit/dev-workspace-symlinks.test.js +112 -0
  4. package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +1 -0
  5. package/dist/__tests__/unit/language-filter.test.d.ts +2 -0
  6. package/dist/__tests__/unit/language-filter.test.d.ts.map +1 -0
  7. package/dist/__tests__/unit/language-filter.test.js +166 -0
  8. package/dist/__tests__/unit/language-filter.test.js.map +1 -0
  9. package/dist/__tests__/unit/output.test.d.ts +2 -0
  10. package/dist/__tests__/unit/output.test.d.ts.map +1 -0
  11. package/dist/__tests__/unit/output.test.js +61 -0
  12. package/dist/__tests__/unit/output.test.js.map +1 -0
  13. package/dist/lib/deps.js +4 -4
  14. package/dist/lib/language-filter.d.ts +31 -0
  15. package/dist/lib/language-filter.d.ts.map +1 -0
  16. package/dist/lib/language-filter.js +14 -0
  17. package/dist/lib/language-filter.js.map +1 -0
  18. package/package.json +3 -3
  19. package/vendored/app/[[...slug]]/page.tsx +12 -4
  20. package/vendored/app/layout.tsx +25 -10
  21. package/vendored/components/mdx/ApiPage.tsx +10 -2
  22. package/vendored/components/mdx/OpenApiEndpoint.tsx +41 -44
  23. package/vendored/components/mdx/YouTube.tsx +8 -0
  24. package/vendored/components/navigation/Sidebar.tsx +32 -17
  25. package/vendored/components/navigation/TabsNav.tsx +22 -30
  26. package/vendored/components/ui/CodePanel.tsx +48 -3
  27. package/vendored/hooks/useIsNavigationSettled.ts +74 -0
  28. package/vendored/lib/layout-helpers.tsx +27 -0
  29. package/vendored/lib/prefetch-batcher.ts +51 -0
  30. package/vendored/lib/prefetch-rsc.ts +19 -0
  31. package/vendored/lib/r2-content.ts +16 -0
  32. package/vendored/lib/r2-feature-flags.ts +7 -0
  33. package/vendored/lib/render-doc-page-openapi-helpers.ts +110 -0
  34. package/vendored/lib/render-doc-page-parallel-helpers.ts +60 -0
  35. package/vendored/lib/render-doc-page.tsx +86 -51
  36. package/vendored/lib/sidebar-prefetch-walker.ts +50 -0
  37. package/vendored/lib/static-artifacts.ts +2 -1
  38. package/vendored/workspace-package-lock.json +98 -96
@@ -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
+ }
@@ -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
+ }
@@ -34,6 +34,9 @@ import { rehypeClassToClassName } from '@/lib/rehype-class-to-classname';
34
34
  import { remarkSvgNamespaceAttrs } from '@/lib/remark-svg-namespace-attrs';
35
35
  import { rehypeNoZoomToData } from '@/lib/rehype-nozoom-to-data';
36
36
  import { rehypeUnwrapNestedAnchors } from './rehype-unwrap-nested-anchors';
37
+ import { loadSnippetsAndInlineComponents } from './render-doc-page-parallel-helpers';
38
+ import { tryOpenApiCandidatesInParallel, makeTryOpenApiSpec, formatFallbackWarning } from './render-doc-page-openapi-helpers';
39
+ import { logger } from '../shared/logger';
37
40
  import { preprocessMdx, containsPanel, containsView, buildSnippetAliasMap } from '@/lib/preprocess-mdx';
38
41
  import { loadSnippetsForIsr } from '@/lib/snippet-loader-isr';
39
42
  import { PanelWrapper } from '@/components/mdx/PanelWrapper';
@@ -229,26 +232,30 @@ export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
229
232
  ? (data.title === config.name ? { absolute: data.title } : data.title)
230
233
  : { absolute: buildSiteTitle(config.name) };
231
234
 
235
+ const markdownHref = `${hostAtDocs ? '/docs/' : '/'}${pagePath}.md`;
236
+
232
237
  return {
233
238
  title: titleValue,
234
239
  description: data.description || '',
235
240
  ...seoMetadata,
236
241
  ...(isRoot && { robots: { index: false, follow: true } }),
237
- ...(data.rss ? {
238
- alternates: {
239
- ...seoMetadata.alternates,
240
- types: {
242
+ alternates: {
243
+ ...seoMetadata.alternates,
244
+ types: {
245
+ 'text/markdown': markdownHref,
246
+ ...(data.rss && {
241
247
  'application/rss+xml': hostAtDocs ? '/docs/feed.xml' : '/feed.xml',
242
- },
248
+ }),
243
249
  },
244
- } : {}),
250
+ },
245
251
  };
246
252
  }
247
253
 
248
254
  export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
249
255
  const { slug: slugInput, projectSlug, hostAtDocs, requestHeaders } = input;
256
+ const isIsr = isIsrMode();
250
257
 
251
- if (isIsrMode()) {
258
+ if (isIsr) {
252
259
  if (!projectSlug) notFound();
253
260
  const exists = await projectExists(projectSlug);
254
261
  if (!exists) notFound();
@@ -256,6 +263,10 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
256
263
 
257
264
  const loader = getContentLoader(projectSlug ?? undefined);
258
265
  const configP = loader.getConfig();
266
+ // Shiki init takes 200-500ms on cold function instances. Kick it off
267
+ // before the content fetch so the two run concurrently. Warm instances
268
+ // resolve from the globalThis singleton instantly.
269
+ const highlighterP = getHighlighter();
259
270
 
260
271
  const normalizedSlug = normalizeSlugForContent(slugInput || [], hostAtDocs);
261
272
  const slug = needsSlugRewrite(normalizedSlug)
@@ -263,9 +274,10 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
263
274
  : normalizedSlug;
264
275
  const pagePath = slug.join('/');
265
276
  const currentLang = extractLanguageFromPath(`/${pagePath}`);
266
- const [fileContents, config] = await Promise.all([
277
+ const [fileContents, config, highlighter] = await Promise.all([
267
278
  loader.getContent(pagePath).catch(() => null),
268
279
  configP,
280
+ highlighterP,
269
281
  ]);
270
282
 
271
283
  if (!fileContents) {
@@ -285,14 +297,14 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
285
297
  });
286
298
  const jsonLdScript = renderJsonLdScript(jsonLd);
287
299
 
288
- const highlighter = await getHighlighter();
300
+ const openApiSpecField = typeof data.openapi === 'string' && data.openapi ? data.openapi : null;
289
301
 
290
- let snippetAliases: Record<string, React.ComponentType<unknown>> = {};
291
- if (isIsrMode() && projectSlug) {
292
- snippetAliases = await loadSnippetsForIsr(projectSlug, rawContent, MDXComponents);
293
- } else {
294
- snippetAliases = buildSnippetAliasMap(rawContent, SnippetComponents);
295
- }
302
+ // Resolve openapi-isr dynamic import concurrently with the snippet/inline
303
+ // parallel block. Win is small — one module-resolve, ~5–30ms on a true cold
304
+ // start, <1ms on warm V8. Kept because the syntactic cost is one ternary.
305
+ const openApiIsrP = openApiSpecField && isIsr && projectSlug
306
+ ? import('@/lib/openapi-isr')
307
+ : null;
296
308
 
297
309
  const content = preprocessMdx(rawContent, { assetVersion: config.assetVersion });
298
310
 
@@ -300,13 +312,29 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
300
312
  .filter(h => typeof h.stepNumber === 'number')
301
313
  .map(h => ({ title: h.text, slug: h.id }));
302
314
 
303
- const { inlineComponents, paramFields } = await extractInlineComponents(content, MDXComponents);
315
+ // [render-timing] proves the snippet/inline parallel win. With Promise.all,
316
+ // wall-clock should be ~max(R2_snippets, CPU_inline). Compare against the
317
+ // per-op `[r2-timing]` for snippet R2: if snippetInlineMs ≈ snippet R2 ms,
318
+ // inline ran "for free" inside the R2 wait. Sum-of-both = parallelism failed.
319
+ const snippetInlineStart = performance.now();
320
+ const { snippetAliases, inlineComponents, paramFields } = await loadSnippetsAndInlineComponents({
321
+ isIsr,
322
+ projectSlug: projectSlug ?? null,
323
+ rawContent,
324
+ preprocessedContent: content,
325
+ mdxComponents: MDXComponents,
326
+ snippetComponents: SnippetComponents,
327
+ loadSnippets: loadSnippetsForIsr,
328
+ buildAlias: buildSnippetAliasMap,
329
+ extractInline: extractInlineComponents,
330
+ });
331
+ const snippetInlineMs = Math.round(performance.now() - snippetInlineStart);
304
332
 
305
333
  const overriddenComponents = Object.keys(inlineComponents).filter(
306
334
  (name) => name in MDXComponents,
307
335
  );
308
336
  if (overriddenComponents.length > 0) {
309
- console.warn(
337
+ logger.warn(
310
338
  `[MDX] Inline component(s) override built-in: ${overriddenComponents.join(', ')} in ${slug.join('/')}`,
311
339
  );
312
340
  }
@@ -339,9 +367,11 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
339
367
  let openApiEndpointData: OpenApiEndpointData | null = null;
340
368
  let openApiCodeExamples: CodeExample[] | null = null;
341
369
  let openApiError: string | null = null;
370
+ let openApiMs: number | null = null;
371
+ let openApiCandidates = 0;
342
372
 
343
373
  let lastFailure: { err: unknown; specPath: string } | null = null;
344
- if (data.openapi && typeof data.openapi === 'string') {
374
+ if (openApiSpecField) {
345
375
  try {
346
376
  const openApiConfig = config.api?.openapi;
347
377
  const allSpecPaths: string[] = typeof openApiConfig === 'string'
@@ -351,7 +381,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
351
381
  : [];
352
382
 
353
383
  const parsed = parseOpenApiFrontmatter(
354
- data.openapi,
384
+ openApiSpecField,
355
385
  allSpecPaths.length > 0 ? allSpecPaths : undefined,
356
386
  );
357
387
 
@@ -359,43 +389,40 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
359
389
  ? allSpecPaths
360
390
  : [parsed.specPath];
361
391
  const specsToTry = baseSpecs.flatMap(p => candidateSpecPaths(p, currentLang));
392
+ openApiCandidates = specsToTry.length;
362
393
 
363
- const useIsr = isIsrMode() && !!projectSlug;
364
- const resolveSpec = useIsr
365
- ? (await import('@/lib/openapi-isr')).resolveOpenApiSpec
394
+ const useIsr = isIsr && !!projectSlug;
395
+ const resolveSpec = useIsr && openApiIsrP
396
+ ? (await openApiIsrP).resolveOpenApiSpec
366
397
  : null;
367
398
  const contentDir = useIsr ? null : getContentDir();
368
399
 
369
- for (let i = 0; i < specsToTry.length; i++) {
370
- const specPath = specsToTry[i];
371
- try {
372
- if (resolveSpec && projectSlug) {
373
- const spec = await resolveSpec(projectSlug, specPath);
374
- openApiEndpointData = parseEndpoint(
375
- spec as Parameters<typeof parseEndpoint>[0],
376
- parsed.method,
377
- parsed.path,
378
- specPath,
379
- );
380
- } else {
381
- const { api } = await getCachedSpec(specPath, contentDir!);
382
- openApiEndpointData = parseEndpoint(api, parsed.method, parsed.path, specPath);
383
- }
384
- lastFailure = null;
385
- break;
386
- } catch (err) {
387
- lastFailure = { err, specPath };
388
- const isLast = i === specsToTry.length - 1;
389
- if (!isLast) {
390
- console.warn(
391
- `[openapi] spec candidate "${specPath}" failed; trying next: ${(err as Error).message}`,
392
- );
393
- }
394
- }
400
+ const tryOne = makeTryOpenApiSpec({
401
+ projectSlug: projectSlug ?? null,
402
+ isIsr: useIsr,
403
+ parsedMethod: parsed.method,
404
+ parsedPath: parsed.path,
405
+ resolveIsrSpec: resolveSpec,
406
+ getStaticSpec: useIsr ? null : getCachedSpec,
407
+ contentDir,
408
+ parseEndpointFn: parseEndpoint,
409
+ });
410
+
411
+ // See `[render-timing]` block above snippetInlineMs for the verification
412
+ // protocol. Same idea here: openApiMs ≈ max(per-spec R2) = parallel.
413
+ const openApiStart = performance.now();
414
+ const result = await tryOpenApiCandidatesInParallel(specsToTry, tryOne);
415
+ openApiMs = Math.round(performance.now() - openApiStart);
416
+ if (result.kind === 'success') {
417
+ openApiEndpointData = result.endpoint;
418
+ lastFailure = null;
419
+ const warning = formatFallbackWarning(specsToTry, result.specPath, parsed.method, parsed.path);
420
+ if (warning) logger.warn(warning);
421
+ } else {
422
+ lastFailure = { err: result.lastError, specPath: result.specPath };
423
+ throw result.lastError;
395
424
  }
396
425
 
397
- if (lastFailure) throw lastFailure.err;
398
-
399
426
  if (openApiEndpointData) {
400
427
  const { method: authMethod, headerName: authHeaderName } = resolveAuth(openApiEndpointData, config);
401
428
  const languages = config.api?.examples?.languages;
@@ -403,13 +430,21 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
403
430
  }
404
431
  } catch (err) {
405
432
  const typed = classifyOpenApiLoadError(err, lastFailure?.specPath ?? null);
406
- console.warn(formatOpenApiWarning(typed));
433
+ logger.warn(formatOpenApiWarning(typed));
407
434
  openApiError = typed.suggestion
408
435
  ? `${typed.message} — ${typed.suggestion}`
409
436
  : typed.message;
410
437
  }
411
438
  }
412
439
 
440
+ logger.info('[render-timing]', {
441
+ region: process.env.VERCEL_REGION || 'unknown',
442
+ pagePath,
443
+ snippetInlineMs,
444
+ openApiMs,
445
+ openApiCandidates,
446
+ });
447
+
413
448
  let mdxApiMethod: HttpMethod | null = null;
414
449
  let mdxApiPath: string | null = null;
415
450
  if (data.api && typeof data.api === 'string' && !data.openapi) {
@@ -0,0 +1,50 @@
1
+ import type { ResolvedGroup } from '@/lib/navigation-resolver';
2
+
3
+ /**
4
+ * Flatten a resolved navigation tree to the list of hrefs that should be
5
+ * prefetched under the existing group-engagement rule from Sidebar.tsx:
6
+ *
7
+ * shouldPrefetch = level !== 0 || isExpanded || !group.name
8
+ *
9
+ * Children of a level-0 group emit only when that group is expanded.
10
+ * Children of nameless or deeper-level groups always emit. Order matches
11
+ * the rendered sidebar so paced prefetches mirror what a user is most
12
+ * likely to click next.
13
+ */
14
+ export function getPrefetchableHrefs(
15
+ groups: ResolvedGroup[],
16
+ expandedGroups: Set<string>,
17
+ linkPrefix: string,
18
+ level: number = 0,
19
+ ): string[] {
20
+ const out: string[] = [];
21
+ const walk = (groups: ResolvedGroup[], level: number) => {
22
+ for (const group of groups) {
23
+ const isExpanded = expandedGroups.has(group.name);
24
+ const groupOpen = level !== 0 || isExpanded || !group.name;
25
+ if (!groupOpen) continue;
26
+
27
+ if (group.items) {
28
+ for (const item of group.items) {
29
+ if (item.type === 'page') {
30
+ out.push(`${linkPrefix}/${item.page.path}`);
31
+ } else if (item.type === 'group') {
32
+ walk([item.group], level + 1);
33
+ }
34
+ }
35
+ } else {
36
+ for (const page of group.pages) {
37
+ out.push(`${linkPrefix}/${page.path}`);
38
+ }
39
+ if (group.nested) walk(group.nested, level + 1);
40
+ }
41
+ }
42
+ };
43
+ walk(groups, level);
44
+
45
+ // Dedup: a docs.json config can reference the same page from two groups
46
+ // (uncommon but legal). Without this, the pacer fires router.prefetch()
47
+ // twice for the same href since scheduleNext walks the items array
48
+ // literally and `seen` is populated up-front, not within the chain.
49
+ return Array.from(new Set(out));
50
+ }
@@ -170,7 +170,8 @@ Disallow: /
170
170
  return `User-agent: *
171
171
  Allow: /
172
172
 
173
- Disallow: /_next/
173
+ Allow: /_next/static/
174
+ Allow: /_next/image
174
175
 
175
176
  Sitemap: ${sitemapUrl || defaultSitemapUrl}
176
177
  `;