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.
- package/dist/__tests__/unit/deps.test.js +184 -0
- package/dist/__tests__/unit/deps.test.js.map +1 -1
- package/dist/__tests__/unit/dev-spinner-ownership.test.d.ts +2 -0
- package/dist/__tests__/unit/dev-spinner-ownership.test.d.ts.map +1 -0
- package/dist/__tests__/unit/dev-spinner-ownership.test.js +37 -0
- package/dist/__tests__/unit/dev-spinner-ownership.test.js.map +1 -0
- 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/__tests__/unit/spinner.test.d.ts +2 -0
- package/dist/__tests__/unit/spinner.test.d.ts.map +1 -0
- package/dist/__tests__/unit/spinner.test.js +83 -0
- package/dist/__tests__/unit/spinner.test.js.map +1 -0
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +13 -3
- package/dist/commands/dev.js.map +1 -1
- package/dist/lib/deps.d.ts +22 -0
- package/dist/lib/deps.d.ts.map +1 -1
- package/dist/lib/deps.js +121 -27
- package/dist/lib/deps.js.map +1 -1
- 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/dist/lib/spinner.d.ts +24 -0
- package/dist/lib/spinner.d.ts.map +1 -1
- package/dist/lib/spinner.js +59 -0
- package/dist/lib/spinner.js.map +1 -1
- 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/middleware-helpers.ts +79 -8
- package/vendored/lib/page-isr-helpers.ts +14 -9
- package/vendored/lib/prefetch-batcher.ts +51 -0
- package/vendored/lib/prefetch-rsc.ts +19 -0
- package/vendored/lib/project-resolver.ts +21 -5
- 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 +101 -52
- package/vendored/lib/sidebar-prefetch-walker.ts +50 -0
- package/vendored/lib/static-artifacts.ts +2 -1
- package/vendored/workspace-package-lock.json +101 -99
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { ReactNode, useEffect, useRef, useState, useCallback } from 'react';
|
|
4
|
+
import { CODEPANEL_TAB_CHANGE_EVENT } from '@/components/ui/CodePanel';
|
|
4
5
|
|
|
5
6
|
interface ApiPageWrapperProps {
|
|
6
7
|
children: ReactNode;
|
|
@@ -28,6 +29,13 @@ function countCodeLines(panel: Element): number {
|
|
|
28
29
|
const codeContent = panel.querySelector('div.grid.grid-cols-\\[minmax\\(0\\,1fr\\)\\]');
|
|
29
30
|
if (!codeContent) return 0;
|
|
30
31
|
|
|
32
|
+
// Loading state: reserve space so the spinner is visible. CodePanel re-fires
|
|
33
|
+
// codepanel-tab-change when the highlighted HTML arrives, which invalidates
|
|
34
|
+
// the cached measurement and brings us back through here for a real count.
|
|
35
|
+
if (codeContent.querySelector('[aria-busy="true"]')) {
|
|
36
|
+
return CODE_BOX_CONFIG.MIN_LINES;
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
// Check for Shiki-rendered code (has pre.shiki)
|
|
32
40
|
const shikiPre = codeContent.querySelector('pre.shiki');
|
|
33
41
|
if (shikiPre) {
|
|
@@ -375,8 +383,8 @@ export function ApiPageWrapper({ children }: ApiPageWrapperProps) {
|
|
|
375
383
|
};
|
|
376
384
|
|
|
377
385
|
const sidebar = sidebarRef.current;
|
|
378
|
-
sidebar.addEventListener(
|
|
379
|
-
return () => sidebar.removeEventListener(
|
|
386
|
+
sidebar.addEventListener(CODEPANEL_TAB_CHANGE_EVENT, handleTabChange);
|
|
387
|
+
return () => sidebar.removeEventListener(CODEPANEL_TAB_CHANGE_EVENT, handleTabChange);
|
|
380
388
|
}, [isDesktop, mounted, applyPanelHeights]);
|
|
381
389
|
|
|
382
390
|
return (
|
|
@@ -4,10 +4,7 @@ import { useDevOnlyNotice, DEV_FEATURE } from '../ui/DevOnlyNotice';
|
|
|
4
4
|
|
|
5
5
|
import { useState, useEffect, useMemo } from 'react';
|
|
6
6
|
import { ApiEndpoint } from './ApiEndpoint';
|
|
7
|
-
import {
|
|
8
|
-
import { CodeGroup } from './CodeGroup';
|
|
9
|
-
import { Tabs, Tab } from './Tabs';
|
|
10
|
-
import { CodePanel, CodePanelTab, getStatusColor } from '../ui/CodePanel';
|
|
7
|
+
import { CodePanel, CodePanelTab, DelayedSpinner } from '../ui/CodePanel';
|
|
11
8
|
import { useShikiHighlightMultiple } from '@/hooks/useShikiHighlight';
|
|
12
9
|
import { preloadHighlighter } from '@/lib/shiki-client';
|
|
13
10
|
import ReactMarkdown from 'react-markdown';
|
|
@@ -76,16 +73,17 @@ function renderSchemaType(schema: JsonSchema): string {
|
|
|
76
73
|
return type || 'any';
|
|
77
74
|
}
|
|
78
75
|
|
|
76
|
+
// Shiki HTML comes from server-side highlighting of trusted code strings;
|
|
77
|
+
// sanitization is unnecessary and would break the syntax-color spans.
|
|
78
|
+
function renderHighlightedHtml(html: string) {
|
|
79
|
+
// eslint-disable-next-line react/no-danger
|
|
80
|
+
return <div dangerouslySetInnerHTML={{ __html: html }} />;
|
|
81
|
+
}
|
|
82
|
+
|
|
79
83
|
/**
|
|
80
84
|
* Render nested schema properties as a list of field items
|
|
81
85
|
*/
|
|
82
|
-
function SchemaProperties({
|
|
83
|
-
schema,
|
|
84
|
-
depth = 0
|
|
85
|
-
}: {
|
|
86
|
-
schema: JsonSchema;
|
|
87
|
-
depth?: number;
|
|
88
|
-
}) {
|
|
86
|
+
function SchemaProperties({ schema }: { schema: JsonSchema }) {
|
|
89
87
|
if (!schema.properties || Object.keys(schema.properties).length === 0) {
|
|
90
88
|
return null;
|
|
91
89
|
}
|
|
@@ -596,21 +594,26 @@ function ResponseExamplePanel({
|
|
|
596
594
|
codeItems.map((item) => ({ code: item.jsonStr, language: item.language }))
|
|
597
595
|
);
|
|
598
596
|
|
|
599
|
-
//
|
|
597
|
+
// While Shiki resolves, tab body shows a delayed spinner — without the
|
|
598
|
+
// isLoading guard we'd flash the hook's escapeHtml placeholder. CodePanel
|
|
599
|
+
// must mount once with a stable [data-code-panel] (see DelayedSpinner in
|
|
600
|
+
// CodePanel.tsx for the hoist constraint that forced this shape).
|
|
600
601
|
const tabs: CodePanelTab[] = useMemo(
|
|
601
602
|
() =>
|
|
602
|
-
codeItems.map((item, index) =>
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
603
|
+
codeItems.map((item, index) => {
|
|
604
|
+
const html = highlightedResults[index];
|
|
605
|
+
return {
|
|
606
|
+
label: item.code,
|
|
607
|
+
statusCode: item.code,
|
|
608
|
+
content: !isLoading && html
|
|
609
|
+
? renderHighlightedHtml(html)
|
|
610
|
+
: <DelayedSpinner />,
|
|
611
|
+
};
|
|
612
|
+
}),
|
|
613
|
+
[codeItems, highlightedResults, isLoading]
|
|
610
614
|
);
|
|
611
615
|
|
|
612
|
-
|
|
613
|
-
if (tabs.length === 0 || isLoading) return null;
|
|
616
|
+
if (tabs.length === 0) return null;
|
|
614
617
|
|
|
615
618
|
return (
|
|
616
619
|
<CodePanel
|
|
@@ -703,13 +706,11 @@ function FieldConstraints({ schema }: { schema: JsonSchema }) {
|
|
|
703
706
|
* Response fields list with parent prefix for nested fields
|
|
704
707
|
* Handles typical API response schemas like { properties: { conference: { type: 'array', items: {...} } } }
|
|
705
708
|
*/
|
|
706
|
-
function ResponseFieldsList({
|
|
707
|
-
schema,
|
|
708
|
-
depth = 0,
|
|
709
|
+
function ResponseFieldsList({
|
|
710
|
+
schema,
|
|
709
711
|
parentName = ''
|
|
710
|
-
}: {
|
|
711
|
-
schema: JsonSchema;
|
|
712
|
-
depth?: number;
|
|
712
|
+
}: {
|
|
713
|
+
schema: JsonSchema;
|
|
713
714
|
parentName?: string;
|
|
714
715
|
}) {
|
|
715
716
|
// If no properties to show, return null
|
|
@@ -729,7 +730,6 @@ function ResponseFieldsList({
|
|
|
729
730
|
parentName={parentName}
|
|
730
731
|
isRequired={required.has(name)}
|
|
731
732
|
isFirst={index === 0}
|
|
732
|
-
depth={depth}
|
|
733
733
|
/>
|
|
734
734
|
))}
|
|
735
735
|
</div>
|
|
@@ -745,14 +745,12 @@ function ResponseFieldItem({
|
|
|
745
745
|
parentName,
|
|
746
746
|
isRequired,
|
|
747
747
|
isFirst,
|
|
748
|
-
depth
|
|
749
748
|
}: {
|
|
750
749
|
name: string;
|
|
751
750
|
schema: JsonSchema;
|
|
752
751
|
parentName: string;
|
|
753
752
|
isRequired: boolean;
|
|
754
753
|
isFirst: boolean;
|
|
755
|
-
depth: number;
|
|
756
754
|
}) {
|
|
757
755
|
const [expanded, setExpanded] = useState(true);
|
|
758
756
|
|
|
@@ -862,20 +860,21 @@ function CodeExamplesSection({ examples }: { examples: CodeExample[] }) {
|
|
|
862
860
|
|
|
863
861
|
const { results: highlightedResults, isLoading } = useShikiHighlightMultiple(codeItems);
|
|
864
862
|
|
|
865
|
-
// Shiki
|
|
863
|
+
// While Shiki resolves, tab body shows a delayed spinner — without the
|
|
864
|
+
// isLoading guard we'd flash the hook's escapeHtml placeholder.
|
|
866
865
|
const tabs: CodePanelTab[] = useMemo(
|
|
867
|
-
() => examples.map((ex, i) =>
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
:
|
|
872
|
-
|
|
873
|
-
|
|
866
|
+
() => examples.map((ex, i) => {
|
|
867
|
+
const html = highlightedResults[i];
|
|
868
|
+
return {
|
|
869
|
+
label: ex.label,
|
|
870
|
+
content: !isLoading && html
|
|
871
|
+
? renderHighlightedHtml(html)
|
|
872
|
+
: <DelayedSpinner />,
|
|
873
|
+
};
|
|
874
|
+
}),
|
|
875
|
+
[examples, highlightedResults, isLoading]
|
|
874
876
|
);
|
|
875
877
|
|
|
876
|
-
// Don't render until highlighting is complete to prevent flash of unformatted content
|
|
877
|
-
if (isLoading) return null;
|
|
878
|
-
|
|
879
878
|
return <CodePanel tabs={tabs} variant="compact" panelType="request" enableFullscreen />;
|
|
880
879
|
}
|
|
881
880
|
|
|
@@ -883,7 +882,6 @@ function CodeExamplesSection({ examples }: { examples: CodeExample[] }) {
|
|
|
883
882
|
* OpenAPI Endpoint Component
|
|
884
883
|
*
|
|
885
884
|
* Renders full endpoint documentation from OpenAPI spec data.
|
|
886
|
-
* Composes existing ParamField, ResponseField, and CodeGroup components.
|
|
887
885
|
*/
|
|
888
886
|
export function OpenApiEndpoint({
|
|
889
887
|
endpoint,
|
|
@@ -900,7 +898,6 @@ export function OpenApiEndpoint({
|
|
|
900
898
|
const {
|
|
901
899
|
method,
|
|
902
900
|
path,
|
|
903
|
-
summary,
|
|
904
901
|
description,
|
|
905
902
|
deprecated,
|
|
906
903
|
parameters,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
|
+
import { preconnect } from 'react-dom';
|
|
2
3
|
import { YouTubeEmbed } from '@next/third-parties/google';
|
|
3
4
|
|
|
4
5
|
interface YouTubeProps {
|
|
@@ -26,6 +27,7 @@ export function YouTube({ id, title, start, short }: YouTubeProps): React.ReactE
|
|
|
26
27
|
const params = startSeconds ? `rel=0&start=${startSeconds}` : 'rel=0';
|
|
27
28
|
|
|
28
29
|
if (short) {
|
|
30
|
+
preconnect('https://www.youtube.com');
|
|
29
31
|
const src = `https://www.youtube.com/embed/${id}?${params}`;
|
|
30
32
|
return (
|
|
31
33
|
<div className="my-6 mx-auto max-w-[360px]">
|
|
@@ -41,6 +43,12 @@ export function YouTube({ id, title, start, short }: YouTubeProps): React.ReactE
|
|
|
41
43
|
);
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
// No crossOrigin — @next/third-parties loads the lite-yt-embed stylesheet
|
|
47
|
+
// and script in no-CORS mode, so a CORS-anonymous preconnect would open a
|
|
48
|
+
// separate connection pool those fetches can't reuse.
|
|
49
|
+
preconnect('https://i.ytimg.com');
|
|
50
|
+
preconnect('https://cdn.jsdelivr.net');
|
|
51
|
+
|
|
44
52
|
return (
|
|
45
53
|
<div className="my-6 [&_lite-youtube]:rounded-xl [&_lite-youtube]:overflow-hidden">
|
|
46
54
|
<YouTubeEmbed
|
|
@@ -44,11 +44,11 @@ interface NavPageProps {
|
|
|
44
44
|
layout: LayoutVariant;
|
|
45
45
|
onNavigate: (href: string) => void;
|
|
46
46
|
linkPrefix?: string; // e.g., '/docs' when hostAtDocs is true
|
|
47
|
-
prefetch?: boolean;
|
|
48
47
|
showSpinner?: boolean;
|
|
48
|
+
prefetch?: boolean;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
export const NavPage = React.memo(function NavPage({ page, pathname, layout, onNavigate, linkPrefix = '',
|
|
51
|
+
export const NavPage = React.memo(function NavPage({ page, pathname, layout, onNavigate, linkPrefix = '', showSpinner = false, prefetch = true }: NavPageProps) {
|
|
52
52
|
const href = `${linkPrefix}/${page.path}`;
|
|
53
53
|
const isActive = pathname === href;
|
|
54
54
|
const colors = page.method ? methodColors[page.method] : null;
|
|
@@ -58,7 +58,14 @@ export const NavPage = React.memo(function NavPage({ page, pathname, layout, onN
|
|
|
58
58
|
<Link
|
|
59
59
|
href={href}
|
|
60
60
|
prefetch={prefetch}
|
|
61
|
-
onClick={() =>
|
|
61
|
+
onClick={(e) => {
|
|
62
|
+
// Modifier-clicks open the link in a new tab — current tab does
|
|
63
|
+
// NOT navigate, so don't mark a navigation pending. We only need
|
|
64
|
+
// to filter modifier keys: middle-click on an <a> dispatches
|
|
65
|
+
// `auxclick`, not `click`, so it never reaches this handler.
|
|
66
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
67
|
+
onNavigate(href);
|
|
68
|
+
}}
|
|
62
69
|
className={`flex items-center gap-2 ${layout === 'header-logo' ? 'pr-3' : 'px-3'} py-1.5 rounded-lg text-sm transition-colors ${
|
|
63
70
|
isActive
|
|
64
71
|
? 'text-[var(--color-primary)] nav-active'
|
|
@@ -163,6 +170,14 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
|
|
|
163
170
|
rawNavigate(url);
|
|
164
171
|
}, [rawNavigate]);
|
|
165
172
|
|
|
173
|
+
// Buttons (group header, anchor, tab) need router.push because they're
|
|
174
|
+
// not <Link>s — Link's own click handler does the navigation, buttons must.
|
|
175
|
+
const handleButtonNav = useCallback((url: string) => {
|
|
176
|
+
userInitiatedNavRef.current = true;
|
|
177
|
+
rawNavigate(url);
|
|
178
|
+
router.push(url);
|
|
179
|
+
}, [rawNavigate, router]);
|
|
180
|
+
|
|
166
181
|
const effectivePathname = pendingPathname || pathname;
|
|
167
182
|
|
|
168
183
|
// Determine effective tabsPosition:
|
|
@@ -324,23 +339,27 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
|
|
|
324
339
|
});
|
|
325
340
|
if (willExpand) {
|
|
326
341
|
const firstPagePath = findFirstPageInGroups([group]);
|
|
327
|
-
if (firstPagePath)
|
|
342
|
+
if (firstPagePath) {
|
|
343
|
+
handleButtonNav(`${linkPrefix}/${firstPagePath}`);
|
|
344
|
+
}
|
|
328
345
|
}
|
|
329
|
-
}, [
|
|
346
|
+
}, [linkPrefix, expandedGroups, handleButtonNav]);
|
|
330
347
|
|
|
331
348
|
// Render a navigation group (supports nesting)
|
|
332
349
|
function renderGroup(group: ResolvedGroup, level: number = 0) {
|
|
333
350
|
const isExpanded = expandedGroups.has(group.name);
|
|
334
|
-
// expandedGroups starts empty and is populated in a useEffect, so the very
|
|
335
|
-
// first render has shouldPrefetch=false for every L0 section; Next.js
|
|
336
|
-
// re-schedules the prefetch when the prop flips to true on the next commit.
|
|
337
|
-
const shouldPrefetch = level !== 0 || isExpanded || !group.name;
|
|
338
351
|
|
|
339
352
|
// Skip rendering if group has no name and no content
|
|
340
353
|
if (!group.name && group.pages.length === 0 && !group.nested?.length && !group.items?.length) {
|
|
341
354
|
return null;
|
|
342
355
|
}
|
|
343
356
|
|
|
357
|
+
// Don't fan out RSC prefetches for collapsed L0 sections on large docs
|
|
358
|
+
// sites. L1+ Links are only in the DOM when their named ancestors are
|
|
359
|
+
// expanded (see the rendering guard below), so `level !== 0` is safe
|
|
360
|
+
// shorthand there.
|
|
361
|
+
const shouldPrefetch = level !== 0 || isExpanded || !group.name;
|
|
362
|
+
|
|
344
363
|
return (
|
|
345
364
|
<div key={group.name || `group-${level}`} className={level === 0 ? 'ml-6' : level === 1 ? '' : 'ml-3'}>
|
|
346
365
|
{group.name && (
|
|
@@ -402,8 +421,8 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
|
|
|
402
421
|
layout={layout}
|
|
403
422
|
onNavigate={handleNavigate}
|
|
404
423
|
linkPrefix={linkPrefix}
|
|
405
|
-
prefetch={shouldPrefetch}
|
|
406
424
|
showSpinner={spinnerPathname === `${linkPrefix}/${p.page.path}`}
|
|
425
|
+
prefetch={shouldPrefetch}
|
|
407
426
|
/>
|
|
408
427
|
))}
|
|
409
428
|
</ul>
|
|
@@ -442,8 +461,8 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
|
|
|
442
461
|
layout={layout}
|
|
443
462
|
onNavigate={handleNavigate}
|
|
444
463
|
linkPrefix={linkPrefix}
|
|
445
|
-
prefetch={shouldPrefetch}
|
|
446
464
|
showSpinner={spinnerPathname === `${linkPrefix}/${page.path}`}
|
|
465
|
+
prefetch={shouldPrefetch}
|
|
447
466
|
/>
|
|
448
467
|
))}
|
|
449
468
|
</ul>
|
|
@@ -495,9 +514,7 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
|
|
|
495
514
|
data-nav-anchor
|
|
496
515
|
data-active={isCurrentAnchor ? 'true' : 'false'}
|
|
497
516
|
onClick={() => {
|
|
498
|
-
if (firstPagePath) {
|
|
499
|
-
router.push(`${linkPrefix}/${firstPagePath}`);
|
|
500
|
-
}
|
|
517
|
+
if (firstPagePath) handleButtonNav(`${linkPrefix}/${firstPagePath}`);
|
|
501
518
|
}}
|
|
502
519
|
className={`flex items-center gap-2.5 ml-6 pr-3 py-1.5 text-sm font-medium transition-colors cursor-pointer ${
|
|
503
520
|
isCurrentAnchor
|
|
@@ -606,9 +623,7 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
|
|
|
606
623
|
<button
|
|
607
624
|
key={tab.name}
|
|
608
625
|
onClick={() => {
|
|
609
|
-
if (firstPagePath) {
|
|
610
|
-
router.push(`${linkPrefix}/${firstPagePath}`);
|
|
611
|
-
}
|
|
626
|
+
if (firstPagePath) handleButtonNav(`${linkPrefix}/${firstPagePath}`);
|
|
612
627
|
}}
|
|
613
628
|
className={`nav-tab-link flex items-center gap-2 px-3 py-1.5 text-sm transition-colors cursor-pointer ${
|
|
614
629
|
isActive
|
|
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
|
|
5
5
|
import { usePathname } from 'next/navigation';
|
|
6
6
|
// Icons use Font Awesome CSS classes for lightweight rendering
|
|
7
7
|
import type { DocsConfig, TabsPosition } from '@/lib/docs-types';
|
|
8
|
-
import { resolveNavigation
|
|
8
|
+
import { resolveNavigation } from '@/lib/navigation-resolver';
|
|
9
9
|
import { getIconClass } from '@/lib/icon-utils';
|
|
10
10
|
import { getTheme } from '@/themes';
|
|
11
11
|
import { getTabsFromConfig } from '@/lib/navigation-utils';
|
|
@@ -24,38 +24,25 @@ export function TabsNav({ config, className = '' }: TabsNavProps) {
|
|
|
24
24
|
const themeConfig = getTheme(config.theme);
|
|
25
25
|
const effectiveTabsPosition: TabsPosition = config.tabsPosition || themeConfig.defaultTabsPosition;
|
|
26
26
|
|
|
27
|
-
// TabsNav is only for 'top' position - when tabs should appear below header
|
|
28
|
-
// When tabsPosition is 'left', tabs are rendered in the Sidebar instead
|
|
29
|
-
if (effectiveTabsPosition !== 'top') {
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
27
|
// Resolve navigation to get tabs and their content
|
|
34
28
|
const resolved = useMemo(() => resolveNavigation(config, pathname), [config, pathname]);
|
|
35
29
|
|
|
36
|
-
//
|
|
37
|
-
const configTabs = getTabsFromConfig(config, pathname);
|
|
30
|
+
// Memoize so the tabsWithPaths memo's deps stay stable across renders.
|
|
31
|
+
const configTabs = useMemo(() => getTabsFromConfig(config, pathname), [config, pathname]);
|
|
38
32
|
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Build tab data with first page paths for linking
|
|
33
|
+
// Build tab data with first page paths for linking. Skip the work when
|
|
34
|
+
// tabsPosition isn't 'top' — we'd return null below anyway, but the
|
|
35
|
+
// useMemo must run unconditionally for rules-of-hooks.
|
|
45
36
|
const tabsWithPaths = useMemo(() => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return
|
|
37
|
+
if (effectiveTabsPosition !== 'top') return [];
|
|
38
|
+
|
|
39
|
+
return configTabs.flatMap((tab, index) => {
|
|
49
40
|
const resolvedTab = resolved.tabs[index];
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
// For now, we'll look them up in resolved.groups
|
|
56
|
-
// This is a simplification - in a full implementation we'd track per-tab groups
|
|
57
|
-
}
|
|
58
|
-
|
|
41
|
+
// configTabs and resolved.tabs select the active language independently;
|
|
42
|
+
// their fallbacks for empty-tab languages differ, so guard against
|
|
43
|
+
// length mismatch rather than throw on `.isExternal` of undefined.
|
|
44
|
+
if (!resolvedTab) return [];
|
|
45
|
+
|
|
59
46
|
// For external tabs, use the href directly
|
|
60
47
|
if (resolvedTab.isExternal && resolvedTab.href) {
|
|
61
48
|
return {
|
|
@@ -132,9 +119,14 @@ export function TabsNav({ config, className = '' }: TabsNavProps) {
|
|
|
132
119
|
isActive,
|
|
133
120
|
};
|
|
134
121
|
});
|
|
135
|
-
}, [configTabs, resolved.tabs, pathname, linkPrefix]);
|
|
136
|
-
|
|
137
|
-
//
|
|
122
|
+
}, [configTabs, resolved.tabs, pathname, linkPrefix, effectiveTabsPosition]);
|
|
123
|
+
|
|
124
|
+
// TabsNav is only for 'top' position - when tabs should appear below header
|
|
125
|
+
// When tabsPosition is 'left', tabs are rendered in the Sidebar instead
|
|
126
|
+
if (effectiveTabsPosition !== 'top') {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
138
130
|
if (tabsWithPaths.length <= 1) {
|
|
139
131
|
return null;
|
|
140
132
|
}
|
|
@@ -42,6 +42,9 @@ export interface CodePanelProps {
|
|
|
42
42
|
className?: string;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Bubbles up to the ApiPage sidebar listener; load-bearing for resize-on-content-change.
|
|
46
|
+
export const CODEPANEL_TAB_CHANGE_EVENT = 'codepanel-tab-change';
|
|
47
|
+
|
|
45
48
|
/**
|
|
46
49
|
* CSS color variables with hardcoded fallbacks
|
|
47
50
|
* Themes can override via --code-panel-* variables
|
|
@@ -150,10 +153,13 @@ export function CodePanel({
|
|
|
150
153
|
}
|
|
151
154
|
};
|
|
152
155
|
|
|
153
|
-
//
|
|
156
|
+
// ApiPage caches each panel's line count on first measure; we re-fire on
|
|
157
|
+
// active-tab content change so the spinner-era height is invalidated when
|
|
158
|
+
// async Shiki finishes.
|
|
159
|
+
const currentTabContent = tabs[activeTab]?.content;
|
|
154
160
|
useEffect(() => {
|
|
155
|
-
panelRef.current?.dispatchEvent(new CustomEvent(
|
|
156
|
-
}, [activeTab]);
|
|
161
|
+
panelRef.current?.dispatchEvent(new CustomEvent(CODEPANEL_TAB_CHANGE_EVENT, { bubbles: true }));
|
|
162
|
+
}, [activeTab, currentTabContent]);
|
|
157
163
|
|
|
158
164
|
// Check for overflow and scroll position
|
|
159
165
|
useEffect(() => {
|
|
@@ -255,6 +261,8 @@ export function CodePanel({
|
|
|
255
261
|
ref={panelRef}
|
|
256
262
|
className={`rounded-xl overflow-hidden not-prose w-full flex flex-col ${className}`}
|
|
257
263
|
style={{ border: `0.5px solid ${codePanelColors.border}`, boxShadow: 'var(--shadow-lg)' }}
|
|
264
|
+
// Load-bearing: ApiPage's MutationObserver hoists [data-code-panel]
|
|
265
|
+
// into the sticky right column. Don't drop or rename this attribute.
|
|
258
266
|
data-code-panel={panelType || 'inline'}
|
|
259
267
|
>
|
|
260
268
|
{/* Tabs header - flex-shrink-0 to maintain size */}
|
|
@@ -522,3 +530,40 @@ export function CodePanel({
|
|
|
522
530
|
</div>
|
|
523
531
|
);
|
|
524
532
|
}
|
|
533
|
+
|
|
534
|
+
interface DelayedSpinnerProps {
|
|
535
|
+
/** Delay before showing the spinner — avoids flash on fast loads. */
|
|
536
|
+
delayMs?: number;
|
|
537
|
+
/** Min body height to reserve so layout doesn't jump on first paint. */
|
|
538
|
+
minHeight?: number;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Use this as `content` of a CodePanel tab while async work resolves. Earlier
|
|
542
|
+
// versions had a separate CodePanelSkeleton component, but ApiPage's
|
|
543
|
+
// MutationObserver hoists `[data-code-panel]` nodes into the sticky sidebar
|
|
544
|
+
// — when React then tried to unmount the skeleton from prose, the node was
|
|
545
|
+
// already in the sidebar and removeChild threw NotFoundError. Swapping tab
|
|
546
|
+
// content keeps the outer `[data-code-panel]` mounted so the hoist happens
|
|
547
|
+
// exactly once.
|
|
548
|
+
export function DelayedSpinner({ delayMs = 500, minHeight = 60 }: DelayedSpinnerProps) {
|
|
549
|
+
const [show, setShow] = useState(false);
|
|
550
|
+
useEffect(() => {
|
|
551
|
+
const t = setTimeout(() => setShow(true), delayMs);
|
|
552
|
+
return () => clearTimeout(t);
|
|
553
|
+
}, [delayMs]);
|
|
554
|
+
|
|
555
|
+
return (
|
|
556
|
+
<div
|
|
557
|
+
className="flex items-center justify-center py-3"
|
|
558
|
+
style={{ minHeight, color: codePanelColors.textMuted }}
|
|
559
|
+
aria-busy="true"
|
|
560
|
+
aria-live="polite"
|
|
561
|
+
>
|
|
562
|
+
<i
|
|
563
|
+
className={`fa-solid fa-circle-notch fa-spin text-base transition-opacity duration-200 ${show ? 'opacity-60' : 'opacity-0'}`}
|
|
564
|
+
aria-hidden="true"
|
|
565
|
+
/>
|
|
566
|
+
<span className="sr-only">Loading code example</span>
|
|
567
|
+
</div>
|
|
568
|
+
);
|
|
569
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
// setTimeout(0) yields one frame so the page's initial RSC paint commits
|
|
6
|
+
// before the prefetch chain kicks off.
|
|
7
|
+
export const SETTLED_DELAY_MS = 0;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Boolean signal: "is it safe to prefetch sidebar links right now?"
|
|
11
|
+
*
|
|
12
|
+
* True iff:
|
|
13
|
+
* - pendingPathname is null (no click-in-flight), AND
|
|
14
|
+
* - document.readyState === 'complete', AND
|
|
15
|
+
* - one frame has elapsed since the last pathname change (setTimeout 0).
|
|
16
|
+
*
|
|
17
|
+
* Resets to false on any input change. The setTimeout(0) frame yield lets
|
|
18
|
+
* the page's initial RSC render commit before we kick off the prefetch
|
|
19
|
+
* chain — without it, sidebar prefetches can race the page's own data
|
|
20
|
+
* fetches inside the same Vercel function pool.
|
|
21
|
+
*/
|
|
22
|
+
export function useIsNavigationSettled(
|
|
23
|
+
pathname: string | null,
|
|
24
|
+
pendingPathname: string | null,
|
|
25
|
+
): boolean {
|
|
26
|
+
const [settled, setSettled] = useState(false);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setSettled(false);
|
|
30
|
+
|
|
31
|
+
if (typeof document === 'undefined') return;
|
|
32
|
+
|
|
33
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
34
|
+
|
|
35
|
+
const startTimer = () => {
|
|
36
|
+
timeoutId = setTimeout(() => {
|
|
37
|
+
setSettled(true);
|
|
38
|
+
}, SETTLED_DELAY_MS);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const cleanup = () => {
|
|
42
|
+
if (timeoutId !== null) {
|
|
43
|
+
clearTimeout(timeoutId);
|
|
44
|
+
timeoutId = null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Don't pace until the user has nothing pending and the page has loaded.
|
|
49
|
+
if (pendingPathname !== null) {
|
|
50
|
+
return cleanup;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (document.readyState === 'complete') {
|
|
54
|
+
startTimer();
|
|
55
|
+
return cleanup;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Wait for the load event, then start the stability timer.
|
|
59
|
+
const onLoad = () => {
|
|
60
|
+
window.removeEventListener('load', onLoad);
|
|
61
|
+
startTimer();
|
|
62
|
+
};
|
|
63
|
+
window.addEventListener('load', onLoad);
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
window.removeEventListener('load', onLoad);
|
|
67
|
+
cleanup();
|
|
68
|
+
};
|
|
69
|
+
// pathname dep handles back/forward and deep-link navs that bypass
|
|
70
|
+
// the click handlers (no pendingPathname flip).
|
|
71
|
+
}, [pathname, pendingPathname]);
|
|
72
|
+
|
|
73
|
+
return settled;
|
|
74
|
+
}
|
|
@@ -25,6 +25,7 @@ import type { DocsConfig, FontConfig, LanguageCode } from '@/lib/docs-types';
|
|
|
25
25
|
import { LinkPrefixProvider } from '@/lib/link-prefix-context';
|
|
26
26
|
import { ProjectSlugProvider } from '@/lib/project-slug-context';
|
|
27
27
|
import { getAnalyticsScript } from '@/lib/analytics-script';
|
|
28
|
+
import { fetchCustomCss, fetchCustomJs } from '@/lib/r2-content';
|
|
28
29
|
|
|
29
30
|
const scrollLockBootstrap = `
|
|
30
31
|
(function() {
|
|
@@ -76,6 +77,32 @@ export function getLocalFileContent(filename: string): string | null {
|
|
|
76
77
|
return null;
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
interface LoadCustomAssetsInput {
|
|
81
|
+
projectSlug: string;
|
|
82
|
+
hasCustomCss: boolean;
|
|
83
|
+
hasCustomJs: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface LoadedCustomAssets {
|
|
87
|
+
customCss: string | null;
|
|
88
|
+
customJs: string | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Both fetches run in parallel — sequential awaits add one cross-continent
|
|
93
|
+
* R2 RTT in non-NA regions (~340ms fra1, ~550ms sin1).
|
|
94
|
+
*/
|
|
95
|
+
export async function loadCustomAssets({
|
|
96
|
+
projectSlug,
|
|
97
|
+
hasCustomCss,
|
|
98
|
+
hasCustomJs,
|
|
99
|
+
}: LoadCustomAssetsInput): Promise<LoadedCustomAssets> {
|
|
100
|
+
const cssP = hasCustomCss ? fetchCustomCss(projectSlug) : Promise.resolve(null);
|
|
101
|
+
const jsP = hasCustomJs ? fetchCustomJs(projectSlug) : Promise.resolve(null);
|
|
102
|
+
const [customCss, customJs] = await Promise.all([cssP, jsP]);
|
|
103
|
+
return { customCss, customJs };
|
|
104
|
+
}
|
|
105
|
+
|
|
79
106
|
export function getThemeCssContent(themeName: ThemeName | undefined): string | null {
|
|
80
107
|
try {
|
|
81
108
|
const theme = getTheme(themeName);
|