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.
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +2 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +1 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js +112 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +1 -0
- package/dist/__tests__/unit/language-filter.test.d.ts +2 -0
- package/dist/__tests__/unit/language-filter.test.d.ts.map +1 -0
- package/dist/__tests__/unit/language-filter.test.js +166 -0
- package/dist/__tests__/unit/language-filter.test.js.map +1 -0
- package/dist/__tests__/unit/output.test.d.ts +2 -0
- package/dist/__tests__/unit/output.test.d.ts.map +1 -0
- package/dist/__tests__/unit/output.test.js +61 -0
- package/dist/__tests__/unit/output.test.js.map +1 -0
- package/dist/lib/deps.js +4 -4
- package/dist/lib/language-filter.d.ts +31 -0
- package/dist/lib/language-filter.d.ts.map +1 -0
- package/dist/lib/language-filter.js +14 -0
- package/dist/lib/language-filter.js.map +1 -0
- package/package.json +3 -3
- package/vendored/app/[[...slug]]/page.tsx +12 -4
- package/vendored/app/layout.tsx +25 -10
- package/vendored/components/mdx/ApiPage.tsx +10 -2
- package/vendored/components/mdx/OpenApiEndpoint.tsx +41 -44
- package/vendored/components/mdx/YouTube.tsx +8 -0
- package/vendored/components/navigation/Sidebar.tsx +32 -17
- package/vendored/components/navigation/TabsNav.tsx +22 -30
- package/vendored/components/ui/CodePanel.tsx +48 -3
- package/vendored/hooks/useIsNavigationSettled.ts +74 -0
- package/vendored/lib/layout-helpers.tsx +27 -0
- package/vendored/lib/prefetch-batcher.ts +51 -0
- package/vendored/lib/prefetch-rsc.ts +19 -0
- package/vendored/lib/r2-content.ts +16 -0
- package/vendored/lib/r2-feature-flags.ts +7 -0
- package/vendored/lib/render-doc-page-openapi-helpers.ts +110 -0
- package/vendored/lib/render-doc-page-parallel-helpers.ts +60 -0
- package/vendored/lib/render-doc-page.tsx +86 -51
- package/vendored/lib/sidebar-prefetch-walker.ts +50 -0
- package/vendored/lib/static-artifacts.ts +2 -1
- 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
|
-
|
|
238
|
-
alternates
|
|
239
|
-
|
|
240
|
-
|
|
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 (
|
|
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
|
|
300
|
+
const openApiSpecField = typeof data.openapi === 'string' && data.openapi ? data.openapi : null;
|
|
289
301
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
364
|
-
const resolveSpec = useIsr
|
|
365
|
-
? (await
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|
+
}
|