jamdesk 1.1.205 → 1.1.207
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/turbopack-loader-config-drift.test.d.ts +20 -0
- package/dist/__tests__/unit/turbopack-loader-config-drift.test.d.ts.map +1 -0
- package/dist/__tests__/unit/turbopack-loader-config-drift.test.js +79 -0
- package/dist/__tests__/unit/turbopack-loader-config-drift.test.js.map +1 -0
- package/dist/__tests__/unit/vendored-sync.test.js +9 -0
- package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
- package/dist/lib/deps.js +3 -3
- package/dist/lib/deps.js.map +1 -1
- package/package.json +5 -5
- package/vendored/app/[[...slug]]/page.tsx +1 -102
- package/vendored/app/api/jd/auth/logout/route.ts +36 -4
- package/vendored/app/api/jd/unlock/route.ts +1 -4
- package/vendored/app/layout.tsx +43 -4
- package/vendored/components/CodeBlockCopyButton.tsx +7 -2
- package/vendored/components/layout/LayoutWrapper.tsx +7 -0
- package/vendored/components/mdx/CodeGroup.tsx +161 -13
- package/vendored/components/mdx/MDXComponents.tsx +93 -50
- package/vendored/components/navigation/Header.tsx +7 -0
- package/vendored/components/navigation/LanguageSelector.tsx +35 -0
- package/vendored/components/navigation/ThemePreviewPicker.tsx +197 -0
- package/vendored/components/ui/CodePanel.tsx +88 -51
- package/vendored/components/ui/CodePanelModal.tsx +57 -36
- package/vendored/hooks/useWheelScrollChaining.ts +181 -0
- package/vendored/lib/auth-plane-write-warning.ts +84 -0
- package/vendored/lib/docs-types.ts +12 -0
- package/vendored/lib/jwt-key-build-warning.ts +38 -0
- package/vendored/lib/language-cookie.ts +20 -0
- package/vendored/lib/language-matcher.ts +124 -0
- package/vendored/lib/language-utils.ts +103 -0
- package/vendored/lib/languages-artifact.ts +160 -0
- package/vendored/lib/layout-helpers.tsx +16 -3
- package/vendored/lib/mdx-import-scan.ts +110 -0
- package/vendored/lib/middleware-helpers.ts +228 -1
- package/vendored/lib/page-timestamps.ts +36 -0
- package/vendored/lib/rehype-code-meta.ts +74 -9
- package/vendored/lib/render-doc-page.tsx +14 -3
- package/vendored/lib/revalidation-helpers.ts +3 -0
- package/vendored/lib/root-page-slug.ts +9 -4
- package/vendored/lib/shiki-transformers.ts +13 -0
- package/vendored/lib/static-artifacts.ts +70 -2
- package/vendored/lib/theme-preview-context.tsx +39 -0
- package/vendored/lib/theme-preview.ts +150 -0
- package/vendored/lib/unlock-audit.ts +10 -0
- package/vendored/next.config.js +32 -0
- package/vendored/schema/docs-schema.json +21 -0
- package/vendored/scripts/turbopack-js-to-ts-loader.cjs +51 -0
- package/vendored/workspace-package-lock.json +231 -177
|
@@ -18,6 +18,15 @@ export interface CodePanelTab {
|
|
|
18
18
|
statusCode?: string;
|
|
19
19
|
/** Font Awesome icon class for the tab (e.g., 'fa-brands fa-js') */
|
|
20
20
|
icon?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Suppress the copy button while THIS tab is the active one. Set from a
|
|
23
|
+
* `nocopy` code fence. Per-tab rather than panel-wide because a CodeGroup
|
|
24
|
+
* mixes flagged and unflagged fences in one panel: the old panel-only flag
|
|
25
|
+
* forced an all-or-nothing choice, and CodeGroup resolved it by honouring
|
|
26
|
+
* nocopy for single-block groups only — so a nocopy fence sharing a group
|
|
27
|
+
* with any sibling silently kept its copy button.
|
|
28
|
+
*/
|
|
29
|
+
hideCopy?: boolean;
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
/**
|
|
@@ -38,6 +47,13 @@ export interface CodePanelProps {
|
|
|
38
47
|
hideTabs?: boolean;
|
|
39
48
|
/** Show expand button to open fullscreen modal */
|
|
40
49
|
enableFullscreen?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Suppress the copy button for every tab, for blocks whose rendered text is
|
|
52
|
+
* not runnable — console transcripts with prompts and output interleaved,
|
|
53
|
+
* redacted values, deliberate counter-examples. A tab's own `hideCopy`
|
|
54
|
+
* overrides this while that tab is active.
|
|
55
|
+
*/
|
|
56
|
+
hideCopy?: boolean;
|
|
41
57
|
/** Additional CSS classes */
|
|
42
58
|
className?: string;
|
|
43
59
|
}
|
|
@@ -114,6 +130,7 @@ export function CodePanel({
|
|
|
114
130
|
title,
|
|
115
131
|
hideTabs = false,
|
|
116
132
|
enableFullscreen = false,
|
|
133
|
+
hideCopy = false,
|
|
117
134
|
className = '',
|
|
118
135
|
}: CodePanelProps) {
|
|
119
136
|
const [activeTab, setActiveTab] = useState(0);
|
|
@@ -133,16 +150,28 @@ export function CodePanel({
|
|
|
133
150
|
// Extract tab labels for sync matching
|
|
134
151
|
const tabLabels = tabs.map(t => t.label);
|
|
135
152
|
|
|
136
|
-
// Sync effect: when selectedLabel changes, switch to matching tab
|
|
153
|
+
// Sync effect: when selectedLabel changes, switch to the matching tab.
|
|
154
|
+
//
|
|
155
|
+
// The "already showing it" guard is load-bearing, not an optimisation.
|
|
156
|
+
// findIndex returns the FIRST match, and two fences in one CodeGroup can
|
|
157
|
+
// resolve to the same tab label: getTabLabel deliberately collapses several
|
|
158
|
+
// languages onto one display string (bash -> cURL), and a translation can
|
|
159
|
+
// collapse two distinct metas into one (projects/mintlify/fr/organize/
|
|
160
|
+
// navigation.mdx does). Without the guard, clicking the second such tab
|
|
161
|
+
// broadcast its label, this effect resolved that echo back to the FIRST
|
|
162
|
+
// index and snapped activeTab there — so the second tab could never be
|
|
163
|
+
// opened at all, permanently. Selection is therefore satisfied by the ACTIVE
|
|
164
|
+
// tab's own label rather than by the first index that happens to carry it,
|
|
165
|
+
// which keeps cross-panel sync intact (a panel showing a different language
|
|
166
|
+
// still switches) while leaving every tab reachable. Deduplicating labels
|
|
167
|
+
// would hide the collision without restoring access, and would change what
|
|
168
|
+
// the reader sees.
|
|
137
169
|
useEffect(() => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
setActiveTab(matchIndex);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
170
|
+
const selected = sync?.selectedLabel?.toLowerCase();
|
|
171
|
+
if (!selected) return;
|
|
172
|
+
if (tabLabels[activeTab]?.toLowerCase() === selected) return;
|
|
173
|
+
const matchIndex = tabLabels.findIndex((label) => label.toLowerCase() === selected);
|
|
174
|
+
if (matchIndex !== -1) setActiveTab(matchIndex);
|
|
146
175
|
}, [sync?.selectedLabel, tabLabels, activeTab]);
|
|
147
176
|
|
|
148
177
|
const handleTabClick = (index: number) => {
|
|
@@ -237,6 +266,9 @@ export function CodePanel({
|
|
|
237
266
|
if (tabs.length === 0) return null;
|
|
238
267
|
|
|
239
268
|
const currentTab = tabs[activeTab] || tabs[0];
|
|
269
|
+
// The active tab decides; the panel-level prop is the fallback for callers
|
|
270
|
+
// that set no per-tab flag (MdxPreBlock's titled branch, RequestExample).
|
|
271
|
+
const copyHidden = currentTab?.hideCopy ?? hideCopy;
|
|
240
272
|
const isCompact = variant === 'compact';
|
|
241
273
|
const fontSize = isCompact ? '11.5px' : '13.44px';
|
|
242
274
|
|
|
@@ -304,27 +336,29 @@ export function CodePanel({
|
|
|
304
336
|
</button>
|
|
305
337
|
)}
|
|
306
338
|
{/* Copy Button */}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
e
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
e
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
339
|
+
{!copyHidden && (
|
|
340
|
+
<button
|
|
341
|
+
onClick={handleCopy}
|
|
342
|
+
className={`p-1.5 rounded-md transition-colors flex-shrink-0 cursor-pointer ${enableFullscreen ? 'ml-1' : ''}`}
|
|
343
|
+
style={{ color: codePanelColors.textMuted }}
|
|
344
|
+
onMouseEnter={(e) => {
|
|
345
|
+
e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
|
|
346
|
+
e.currentTarget.style.color = codePanelColors.text;
|
|
347
|
+
}}
|
|
348
|
+
onMouseLeave={(e) => {
|
|
349
|
+
e.currentTarget.style.backgroundColor = 'transparent';
|
|
350
|
+
e.currentTarget.style.color = codePanelColors.textMuted;
|
|
351
|
+
}}
|
|
352
|
+
title="Copy code"
|
|
353
|
+
aria-label="Copy code to clipboard"
|
|
354
|
+
>
|
|
355
|
+
{copied ? (
|
|
356
|
+
<i className="fa-solid fa-check text-[14px] text-emerald-500" aria-hidden="true" />
|
|
357
|
+
) : (
|
|
358
|
+
<i className="fa-regular fa-copy text-[14px]" aria-hidden="true" />
|
|
359
|
+
)}
|
|
360
|
+
</button>
|
|
361
|
+
)}
|
|
328
362
|
</div>
|
|
329
363
|
</div>
|
|
330
364
|
) : (
|
|
@@ -432,27 +466,29 @@ export function CodePanel({
|
|
|
432
466
|
</button>
|
|
433
467
|
)}
|
|
434
468
|
{/* Fixed copy button */}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
e
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
e
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
469
|
+
{!copyHidden && (
|
|
470
|
+
<button
|
|
471
|
+
onClick={handleCopy}
|
|
472
|
+
className={`p-1.5 rounded-md transition-colors flex-shrink-0 cursor-pointer ${enableFullscreen ? 'ml-1' : 'ml-auto'}`}
|
|
473
|
+
style={{ color: codePanelColors.textMuted }}
|
|
474
|
+
onMouseEnter={(e) => {
|
|
475
|
+
e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
|
|
476
|
+
e.currentTarget.style.color = codePanelColors.text;
|
|
477
|
+
}}
|
|
478
|
+
onMouseLeave={(e) => {
|
|
479
|
+
e.currentTarget.style.backgroundColor = 'transparent';
|
|
480
|
+
e.currentTarget.style.color = codePanelColors.textMuted;
|
|
481
|
+
}}
|
|
482
|
+
title="Copy code"
|
|
483
|
+
aria-label="Copy code to clipboard"
|
|
484
|
+
>
|
|
485
|
+
{copied ? (
|
|
486
|
+
<i className="fa-solid fa-check text-[14px] text-emerald-500" aria-hidden="true" />
|
|
487
|
+
) : (
|
|
488
|
+
<i className="fa-regular fa-copy text-[14px]" aria-hidden="true" />
|
|
489
|
+
)}
|
|
490
|
+
</button>
|
|
491
|
+
)}
|
|
456
492
|
</div>
|
|
457
493
|
{/* Custom scrollbar track - always visible when there's overflow */}
|
|
458
494
|
{hasOverflow && (
|
|
@@ -525,6 +561,7 @@ export function CodePanel({
|
|
|
525
561
|
tabs={tabs}
|
|
526
562
|
title={title}
|
|
527
563
|
initialTabIndex={activeTab}
|
|
564
|
+
hideCopy={hideCopy}
|
|
528
565
|
/>
|
|
529
566
|
)}
|
|
530
567
|
</div>
|
|
@@ -11,9 +11,23 @@ interface CodePanelModalProps {
|
|
|
11
11
|
tabs: CodePanelTab[];
|
|
12
12
|
title?: string;
|
|
13
13
|
initialTabIndex?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Suppress the header copy button, Cmd/Ctrl+C shortcut, and its footer hint
|
|
16
|
+
* for every tab. A tab's own `hideCopy` overrides this while it is active —
|
|
17
|
+
* the modal tracks its own activeTab, so it resolves this itself rather than
|
|
18
|
+
* receiving the panel's already-resolved value.
|
|
19
|
+
*/
|
|
20
|
+
hideCopy?: boolean;
|
|
14
21
|
}
|
|
15
22
|
|
|
16
|
-
export function CodePanelModal({
|
|
23
|
+
export function CodePanelModal({
|
|
24
|
+
isOpen,
|
|
25
|
+
onClose,
|
|
26
|
+
tabs,
|
|
27
|
+
title,
|
|
28
|
+
initialTabIndex = 0,
|
|
29
|
+
hideCopy = false,
|
|
30
|
+
}: CodePanelModalProps) {
|
|
17
31
|
const [activeTab, setActiveTab] = useState(initialTabIndex);
|
|
18
32
|
const [copied, setCopied] = useState(false);
|
|
19
33
|
const [isClosing, setIsClosing] = useState(false);
|
|
@@ -22,6 +36,9 @@ export function CodePanelModal({ isOpen, onClose, tabs, title, initialTabIndex =
|
|
|
22
36
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
23
37
|
|
|
24
38
|
const currentTab = tabs[activeTab];
|
|
39
|
+
// Must agree with CodePanel's own resolution, or expanding a nocopy tab to
|
|
40
|
+
// fullscreen would hand back the copy button the panel just suppressed.
|
|
41
|
+
const copyHidden = currentTab?.hideCopy ?? hideCopy;
|
|
25
42
|
|
|
26
43
|
// Handle close with animation
|
|
27
44
|
const handleClose = useCallback(() => {
|
|
@@ -87,7 +104,7 @@ export function CodePanelModal({ isOpen, onClose, tabs, title, initialTabIndex =
|
|
|
87
104
|
}
|
|
88
105
|
|
|
89
106
|
// Cmd/Ctrl+C to copy (when not selecting text)
|
|
90
|
-
if ((e.metaKey || e.ctrlKey) && e.key === 'c' && !window.getSelection()?.toString()) {
|
|
107
|
+
if (!copyHidden && (e.metaKey || e.ctrlKey) && e.key === 'c' && !window.getSelection()?.toString()) {
|
|
91
108
|
e.preventDefault();
|
|
92
109
|
handleCopy();
|
|
93
110
|
return;
|
|
@@ -113,7 +130,7 @@ export function CodePanelModal({ isOpen, onClose, tabs, title, initialTabIndex =
|
|
|
113
130
|
|
|
114
131
|
document.addEventListener('keydown', handleKeyDown);
|
|
115
132
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
116
|
-
}, [isOpen, handleClose, handleCopy]);
|
|
133
|
+
}, [isOpen, handleClose, handleCopy, copyHidden]);
|
|
117
134
|
|
|
118
135
|
if (!isOpen) return null;
|
|
119
136
|
|
|
@@ -224,27 +241,29 @@ export function CodePanelModal({ isOpen, onClose, tabs, title, initialTabIndex =
|
|
|
224
241
|
{/* Right: Actions */}
|
|
225
242
|
<div className="flex items-center gap-2">
|
|
226
243
|
{/* Copy Button */}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
e
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
e
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
244
|
+
{!copyHidden && (
|
|
245
|
+
<button
|
|
246
|
+
onClick={handleCopy}
|
|
247
|
+
className="p-2 rounded-md transition-colors cursor-pointer"
|
|
248
|
+
style={{ color: codePanelColors.textMuted }}
|
|
249
|
+
onMouseEnter={(e) => {
|
|
250
|
+
e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
|
|
251
|
+
e.currentTarget.style.color = codePanelColors.text;
|
|
252
|
+
}}
|
|
253
|
+
onMouseLeave={(e) => {
|
|
254
|
+
e.currentTarget.style.backgroundColor = 'transparent';
|
|
255
|
+
e.currentTarget.style.color = codePanelColors.textMuted;
|
|
256
|
+
}}
|
|
257
|
+
title="Copy code"
|
|
258
|
+
aria-label="Copy code to clipboard"
|
|
259
|
+
>
|
|
260
|
+
{copied ? (
|
|
261
|
+
<i className="fa-solid fa-check text-emerald-500" aria-hidden="true" />
|
|
262
|
+
) : (
|
|
263
|
+
<i className="fa-regular fa-copy" aria-hidden="true" />
|
|
264
|
+
)}
|
|
265
|
+
</button>
|
|
266
|
+
)}
|
|
248
267
|
|
|
249
268
|
{/* Close Button */}
|
|
250
269
|
<button
|
|
@@ -304,18 +323,20 @@ export function CodePanelModal({ isOpen, onClose, tabs, title, initialTabIndex =
|
|
|
304
323
|
</kbd>
|
|
305
324
|
<span>Close</span>
|
|
306
325
|
</span>
|
|
307
|
-
|
|
308
|
-
<
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
326
|
+
{!copyHidden && (
|
|
327
|
+
<span className="flex items-center gap-1.5">
|
|
328
|
+
<kbd
|
|
329
|
+
className="px-1.5 py-0.5 rounded text-[10px] font-mono"
|
|
330
|
+
style={{
|
|
331
|
+
backgroundColor: codePanelColors.tabHoverBg,
|
|
332
|
+
border: `0.5px solid ${codePanelColors.border}`,
|
|
333
|
+
}}
|
|
334
|
+
>
|
|
335
|
+
{typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}+C
|
|
336
|
+
</kbd>
|
|
337
|
+
<span>Copy</span>
|
|
338
|
+
</span>
|
|
339
|
+
)}
|
|
319
340
|
</div>
|
|
320
341
|
{currentTab?.language && (
|
|
321
342
|
<span className="uppercase tracking-wide">{currentTab.language}</span>
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Restores native scroll chaining for the desktop three-column layout.
|
|
7
|
+
*
|
|
8
|
+
* At >=1024px the layout shell is `lg:h-screen lg:overflow-hidden`
|
|
9
|
+
* (LayoutWrapper), so the document never scrolls — `#content-scroll-container`
|
|
10
|
+
* does. That makes every sibling column a scroll dead end: the browser looks
|
|
11
|
+
* for an ancestor to chain the wheel to, finds only `overflow: hidden`, and
|
|
12
|
+
* drops the event.
|
|
13
|
+
*
|
|
14
|
+
* The worst case is the TOC <aside>, which PageColumns renders unconditionally
|
|
15
|
+
* at xl+ while TableOfContents returns null when the page has no h2/h3. On a
|
|
16
|
+
* heading-less page that leaves ~288px (a quarter of a 1280px viewport) where
|
|
17
|
+
* the wheel does nothing at all. A TOC too short to overflow, a short sidebar
|
|
18
|
+
* nav, and the shell's own horizontal padding fail the same way.
|
|
19
|
+
*
|
|
20
|
+
* This walks up from the event target looking for any scroll container. If
|
|
21
|
+
* there is none, the delta is handed to the content column — which is what the
|
|
22
|
+
* browser would have done had the ancestors not been `overflow: hidden`.
|
|
23
|
+
*
|
|
24
|
+
* Scope is deliberately narrower than true scroll chaining: a column that CAN
|
|
25
|
+
* scroll but has hit its end keeps the wheel and stops, exactly as it does
|
|
26
|
+
* today. Widening that is a separate, visible behaviour change.
|
|
27
|
+
*
|
|
28
|
+
* Listener is passive: nothing else was going to scroll, so there is no
|
|
29
|
+
* default to prevent and no scroll-blocking cost.
|
|
30
|
+
*
|
|
31
|
+
* Verifying this with Playwright: its synthetic wheel reports a deltaY twice
|
|
32
|
+
* what Chrome actually applies (confirmed on a bare overflow-y:auto box —
|
|
33
|
+
* event 200 -> 100px scrolled). The harness can show WHETHER a region scrolls,
|
|
34
|
+
* never HOW FAR. Real wheel events are 1:1 in DOM_DELTA_PIXEL.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Fallback px-per-line for wheels reporting DOM_DELTA_LINE (Firefox). */
|
|
38
|
+
const LINE_HEIGHT_PX = 16;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Does `el` own this wheel event — either by declaring it, or by being a
|
|
42
|
+
* scroll container with somewhere left to go in either direction?
|
|
43
|
+
*
|
|
44
|
+
* Deliberately direction-agnostic. A sidebar already scrolled to its bottom
|
|
45
|
+
* still counts as "owns the wheel", so this hook does not take the wheel away
|
|
46
|
+
* from a column that merely ran out of room — that would be full scroll
|
|
47
|
+
* chaining, a behaviour change well beyond restoring the dead zones. Only a
|
|
48
|
+
* region that can never scroll at all hands its wheel to the content column.
|
|
49
|
+
*
|
|
50
|
+
* Only overflowY auto/scroll qualifies: a code block with `overflow-x: auto`
|
|
51
|
+
* and `overflow-y: hidden` must stay transparent to a vertical wheel, or every
|
|
52
|
+
* wide snippet becomes its own dead zone.
|
|
53
|
+
*
|
|
54
|
+
* The ORDER of the reads below is load-bearing, not incidental. overflowY and
|
|
55
|
+
* overscrollBehaviorY are style-only resolved values — reading them can cost a
|
|
56
|
+
* style recalc, never a layout. scrollHeight/clientHeight ARE layout-dependent
|
|
57
|
+
* and flush layout if it is dirty. Because the overflow test gates them, an
|
|
58
|
+
* ancestor that is not a scroll container costs one cheap read, and a typical
|
|
59
|
+
* 6-10 node walk forces zero layouts. Hoisting the scroll-range check above the
|
|
60
|
+
* overflow check would make every wheel event force layout on every ancestor.
|
|
61
|
+
*/
|
|
62
|
+
function ownsTheWheel(el: Element): boolean {
|
|
63
|
+
const style = getComputedStyle(el);
|
|
64
|
+
|
|
65
|
+
// `overscroll-behavior-y: contain | none` is an author saying explicitly
|
|
66
|
+
// "scrolling must not chain out of me". Honour it whether or not the element
|
|
67
|
+
// currently has room — an empty chat transcript or a short playground pane
|
|
68
|
+
// still means it, and forwarding past it would scroll the docs underneath.
|
|
69
|
+
// Used by ChatPanel, PlaygroundModal and Prompt.
|
|
70
|
+
const overscrollY = style.overscrollBehaviorY;
|
|
71
|
+
if (overscrollY === 'contain' || overscrollY === 'none') return true;
|
|
72
|
+
|
|
73
|
+
const overflowY = style.overflowY;
|
|
74
|
+
if (overflowY !== 'auto' && overflowY !== 'scroll') return false;
|
|
75
|
+
// Sub-pixel heights (zoom, fractional DPR) can leave a hairline of fake
|
|
76
|
+
// scroll range, so require more than a pixel before calling it scrollable.
|
|
77
|
+
return el.scrollHeight - el.clientHeight > 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Can the content column still move in the direction of `deltaY`? */
|
|
81
|
+
function canConsume(el: Element, deltaY: number): boolean {
|
|
82
|
+
const overflowY = getComputedStyle(el).overflowY;
|
|
83
|
+
if (overflowY !== 'auto' && overflowY !== 'scroll') return false;
|
|
84
|
+
if (el.scrollHeight - el.clientHeight <= 1) return false;
|
|
85
|
+
return deltaY > 0
|
|
86
|
+
? el.scrollTop + el.clientHeight < el.scrollHeight - 1
|
|
87
|
+
: el.scrollTop > 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Normalise wheel delta to pixels — deltaY is lines in mode 1, pages in mode 2. */
|
|
91
|
+
function deltaToPixels(event: WheelEvent, viewportHeight: number): number {
|
|
92
|
+
if (event.deltaMode === 1) return event.deltaY * LINE_HEIGHT_PX;
|
|
93
|
+
if (event.deltaMode === 2) return event.deltaY * viewportHeight;
|
|
94
|
+
return event.deltaY;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Is a modal holding a body scroll lock right now?
|
|
99
|
+
*
|
|
100
|
+
* `useBodyScrollLock` sets `document.body.style.overflow = 'hidden'` and
|
|
101
|
+
* nothing else. The `html[data-scroll-locked]` CSS rule in globals.css that
|
|
102
|
+
* DOES neutralise the content column is first-paint only — `scrollLockBootstrap`
|
|
103
|
+
* removes the attribute within ~100ms of DOMContentLoaded, long before any
|
|
104
|
+
* modal opens. So with Cmd+K open the content column is still `overflow-y:
|
|
105
|
+
* auto` and still scrollable, and without this check the wheel over a modal's
|
|
106
|
+
* dimmed backdrop scrolled the docs behind it (verified live).
|
|
107
|
+
*/
|
|
108
|
+
function isScrollLocked(): boolean {
|
|
109
|
+
// Inline first: that is literally what useBodyScrollLock writes, and it is
|
|
110
|
+
// the one form jsdom reports faithfully (it does not expand the `overflow`
|
|
111
|
+
// shorthand into computed `overflowY`). The computed check stays as the
|
|
112
|
+
// catch-all for a lock applied via a stylesheet instead. Both were confirmed
|
|
113
|
+
// to read 'hidden' in Chrome with the search modal open.
|
|
114
|
+
if (document.body.style.overflow === 'hidden') return true;
|
|
115
|
+
return getComputedStyle(document.body).overflowY === 'hidden';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param enabled - pass false in embed mode. PageColumns emits
|
|
120
|
+
* `#content-scroll-container` unconditionally (only the TOC aside is
|
|
121
|
+
* embed-gated), so the hook is NOT inert there by construction — it only
|
|
122
|
+
* happens to no-op because the embed shell is `min-h-screen`, leaving the
|
|
123
|
+
* column unbounded. Any future bounded height would switch it on inside a
|
|
124
|
+
* customer's embedded widget.
|
|
125
|
+
*/
|
|
126
|
+
export function useWheelScrollChaining(enabled = true): void {
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (!enabled) return;
|
|
129
|
+
|
|
130
|
+
const handleWheel = (event: WheelEvent) => {
|
|
131
|
+
// ctrl+wheel is pinch-zoom, not a scroll. shift+wheel is the browser's
|
|
132
|
+
// horizontal-scroll modifier — it still arrives as deltaY, and native
|
|
133
|
+
// chaining would look for somewhere HORIZONTAL to go, find nothing in a
|
|
134
|
+
// dead zone, and do nothing. Scrolling vertically instead would invent
|
|
135
|
+
// behaviour the browser never had.
|
|
136
|
+
if (event.ctrlKey || event.shiftKey || event.deltaY === 0) return;
|
|
137
|
+
// A snippet can register its own non-passive wheel handler (a pan/zoom
|
|
138
|
+
// canvas, a custom carousel) and call preventDefault. Nothing in
|
|
139
|
+
// build-service does today, but customer `/snippets` 'use client' files
|
|
140
|
+
// can, and overriding them would scroll the docs out from under the
|
|
141
|
+
// widget. React's onWheel is passive at the root, so this only catches
|
|
142
|
+
// native listeners — which are exactly the ones that can mean it.
|
|
143
|
+
if (event.defaultPrevented) return;
|
|
144
|
+
if (isScrollLocked()) return;
|
|
145
|
+
|
|
146
|
+
const content = document.querySelector<HTMLElement>('#content-scroll-container');
|
|
147
|
+
if (!content) return;
|
|
148
|
+
|
|
149
|
+
// Below lg this is `overflow-y: visible`, so the document scrolls
|
|
150
|
+
// normally and native chaining already works — bail out there.
|
|
151
|
+
if (!canConsume(content, event.deltaY)) return;
|
|
152
|
+
|
|
153
|
+
// Element, not HTMLElement: an SVG target (D2/Mermaid diagrams, inline
|
|
154
|
+
// icons) is an SVGElement, and narrowing to HTMLElement skipped the walk
|
|
155
|
+
// entirely — the delta was then forwarded on top of the browser's own
|
|
156
|
+
// scroll, doubling scroll speed over every diagram.
|
|
157
|
+
const target: Element | null =
|
|
158
|
+
event.target instanceof Element ? event.target : null;
|
|
159
|
+
|
|
160
|
+
// The chat panel is its own surface, not page chrome. Before any message
|
|
161
|
+
// arrives it has nothing to scroll, and forwarding there would scroll the
|
|
162
|
+
// docs behind an open panel — surprising, and not the dead zone we set
|
|
163
|
+
// out to fix. Leave it inert, exactly as it is today.
|
|
164
|
+
if (target?.closest('[data-chat-panel]')) return;
|
|
165
|
+
|
|
166
|
+
let node: Element | null = target;
|
|
167
|
+
|
|
168
|
+
while (node) {
|
|
169
|
+
// Something under the cursor owns the wheel — let the browser do it.
|
|
170
|
+
if (ownsTheWheel(node)) return;
|
|
171
|
+
if (node === document.documentElement) break;
|
|
172
|
+
node = node.parentElement;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
content.scrollTop += deltaToPixels(event, content.clientHeight);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
window.addEventListener('wheel', handleWheel, { passive: true });
|
|
179
|
+
return () => window.removeEventListener('wheel', handleWheel);
|
|
180
|
+
}, [enabled]);
|
|
181
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { BuildWarning } from '../shared/status-reporter.js'; // vendored copy — NOT '../../shared'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Warn when a gated project finished a build without its auth plane written.
|
|
5
|
+
*
|
|
6
|
+
* `setProjectAuthPublic` is the only thing that puts a project's gate config
|
|
7
|
+
* where the edge can read it, and in build.ts it sits inside a try whose catch
|
|
8
|
+
* logs `Failed to write projectAuthPublic to Redis (non-fatal)` and lets the
|
|
9
|
+
* build go green. That non-fatal choice is right for availability and wrong for
|
|
10
|
+
* visibility: `resolveAuth` returns null when the key is absent, and a null
|
|
11
|
+
* resolve means `applyAuthGate` returns null, which is NO GATE. So the failure
|
|
12
|
+
* mode of a dropped write is a fully public site reported as a successful build.
|
|
13
|
+
*
|
|
14
|
+
* Two ways in, neither of them a misconfiguration:
|
|
15
|
+
* - Upstash is briefly unavailable during the write.
|
|
16
|
+
* - The payload outgrows what `upstashCommand` can put in a URL path. It
|
|
17
|
+
* encodes the whole value into the path, and a few hundred group-restricted
|
|
18
|
+
* pages is tens of KB of URL, so this one arrives through ordinary growth on
|
|
19
|
+
* a project that has been fine for months.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately mode-agnostic. The fail-open lives in `resolveAuth` returning
|
|
22
|
+
* null, which happens before authType is ever consulted, so a password tenant
|
|
23
|
+
* loses its gate exactly as a jwt tenant does. Silent when `enabled` is false,
|
|
24
|
+
* where serving publicly is the stated intent.
|
|
25
|
+
*
|
|
26
|
+
* This warns rather than fails the build. Failing would be the safer default on
|
|
27
|
+
* the ordering alone (the write precedes the R2 upload, so a hard fail would
|
|
28
|
+
* stop unprotected content from publishing at all), but it converts every
|
|
29
|
+
* transient Upstash blip into a broken customer build. That tradeoff is a
|
|
30
|
+
* product call, not a build-service one.
|
|
31
|
+
*/
|
|
32
|
+
export function buildAuthPlaneWriteWarning(input: {
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
writeFailed: boolean;
|
|
35
|
+
}): BuildWarning | null {
|
|
36
|
+
if (!input.enabled || !input.writeFailed) return null;
|
|
37
|
+
return {
|
|
38
|
+
// Same reuse, and the same reasoning, as buildJwtKeyWarning: this routing
|
|
39
|
+
// (emailed, counted, badged) is what the warning needs, and a dedicated
|
|
40
|
+
// BuildWarningType would mean the six-place sync chain for no difference.
|
|
41
|
+
type: 'invalid_openapi_spec',
|
|
42
|
+
file: 'docs.json',
|
|
43
|
+
message:
|
|
44
|
+
'This project is password or JWT protected, but the build could not publish its access rules, ' +
|
|
45
|
+
'so the site is currently readable by anyone. Rebuild to retry. ' +
|
|
46
|
+
'If it keeps failing, the access rules may have grown too large to store and support can raise it.',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Does docs.json itself declare a gate? Read BEFORE the auth try block, so the
|
|
52
|
+
* warning above survives a throw inside it.
|
|
53
|
+
*
|
|
54
|
+
* The gap this closes: `authPlaneGated` used to be assigned only from
|
|
55
|
+
* `effectiveEnabled`, ~19 lines into the try, after `pageInfos.map` and
|
|
56
|
+
* `detectAuthMode`. Anything throwing in that window skipped both the
|
|
57
|
+
* assignment AND `setProjectAuthPublic`, so the site went public and the
|
|
58
|
+
* warning was suppressed by the same throw. That window is reachable from
|
|
59
|
+
* user-supplied config: `collectGroupsPaths` does
|
|
60
|
+
* `(navigation?.languages ?? []).map(...)`, and `??` only guards null and
|
|
61
|
+
* undefined, so a `languages` that is a string throws.
|
|
62
|
+
*
|
|
63
|
+
* Mirrors `detectAuthMode`'s own test exactly (`?.enabled === true`), never
|
|
64
|
+
* truthiness — 'false' and 0 are not gates there and must not be gates here,
|
|
65
|
+
* or the warning fires on a site that is public by intent.
|
|
66
|
+
*
|
|
67
|
+
* MUST NOT THROW. Optional chaining is safe against any non-null value, which
|
|
68
|
+
* is the point: docs.json is user JSON and `auth` can be a string, an array or
|
|
69
|
+
* a number. Callers combine this monotonically (`a || b`) with the in-try
|
|
70
|
+
* value, so it can only ever turn the warning on.
|
|
71
|
+
*
|
|
72
|
+
* Residual, named rather than hidden: `specific` mode is frontmatter-derived
|
|
73
|
+
* (`collectPrivatePaths`), so a tenant gated only by per-page `private`
|
|
74
|
+
* frontmatter reads false here. A throw before `detectAuthMode` on such a
|
|
75
|
+
* tenant still goes unwarned. Covering it would mean replicating the
|
|
76
|
+
* frontmatter walk outside the try, which reintroduces the throw it guards.
|
|
77
|
+
*/
|
|
78
|
+
export function configDeclaresGate(docsConfig: unknown): boolean {
|
|
79
|
+
const auth = (docsConfig as { auth?: unknown } | null | undefined)?.auth as {
|
|
80
|
+
password?: { enabled?: unknown };
|
|
81
|
+
jwt?: { enabled?: unknown };
|
|
82
|
+
} | undefined;
|
|
83
|
+
return auth?.password?.enabled === true || auth?.jwt?.enabled === true;
|
|
84
|
+
}
|
|
@@ -806,6 +806,17 @@ export interface MetadataConfig {
|
|
|
806
806
|
timestamp?: boolean;
|
|
807
807
|
}
|
|
808
808
|
|
|
809
|
+
/**
|
|
810
|
+
* Localization behaviour.
|
|
811
|
+
*
|
|
812
|
+
* `autoRedirect` opts a project into server-side Accept-Language routing at
|
|
813
|
+
* locale roots. Default OFF: turning it on changes what a first-time visitor
|
|
814
|
+
* sees, so it is never inferred from the presence of `navigation.languages`.
|
|
815
|
+
*/
|
|
816
|
+
export interface LocalizationConfig {
|
|
817
|
+
autoRedirect?: boolean;
|
|
818
|
+
}
|
|
819
|
+
|
|
809
820
|
/**
|
|
810
821
|
* Icons library configuration
|
|
811
822
|
*/
|
|
@@ -947,6 +958,7 @@ export interface DocsConfig {
|
|
|
947
958
|
thumbnails?: ThumbnailsConfig;
|
|
948
959
|
interaction?: InteractionConfig;
|
|
949
960
|
metadata?: MetadataConfig;
|
|
961
|
+
localization?: LocalizationConfig;
|
|
950
962
|
analytics?: AnalyticsConfig;
|
|
951
963
|
chat?: ChatConfig;
|
|
952
964
|
spellcheck?: SpellcheckConfig;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { BuildWarning } from '../shared/status-reporter.js'; // vendored copy — NOT '../../shared'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Warn when a project declares JWT authentication but has no signing key.
|
|
5
|
+
*
|
|
6
|
+
* The truth table in auth-resolver.ts is explicit: `authType === 'jwt'` with a
|
|
7
|
+
* missing or malformed `projectJwtSecret` means NO GATE. That is the correct
|
|
8
|
+
* fail-safe for a key that was cleared deliberately, but it is also reachable
|
|
9
|
+
* by accident: commit `auth.jwt.enabled: true`, publish, and never click
|
|
10
|
+
* Generate signing key. The build then succeeds, the site serves every page to
|
|
11
|
+
* the public, and nothing says so. The dashboard card does report it, but a
|
|
12
|
+
* customer who publishes from git has no reason to open that page.
|
|
13
|
+
*
|
|
14
|
+
* Silent for `enabled: false`, where a public site is the stated intent, and
|
|
15
|
+
* silent for password tenants, whose own secret has its own row in that table.
|
|
16
|
+
* A password tenant can legitimately still hold a JWT key from a half-finished
|
|
17
|
+
* migration; coexistence is by design and only `authType` decides.
|
|
18
|
+
*/
|
|
19
|
+
export function buildJwtKeyWarning(input: {
|
|
20
|
+
authType: 'password' | 'jwt';
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
hasSigningKey: boolean;
|
|
23
|
+
}): BuildWarning | null {
|
|
24
|
+
if (input.authType !== 'jwt' || !input.enabled || input.hasSigningKey) return null;
|
|
25
|
+
return {
|
|
26
|
+
// Reuses invalid_openapi_spec deliberately: it already routes the way this
|
|
27
|
+
// needs — emailed, counted, badged, not filed as a suggestion — and a
|
|
28
|
+
// dedicated BuildWarningType would mean editing the six-place sync chain
|
|
29
|
+
// plus EMAILABLE_TYPES for no behavioural difference. Same reasoning, and
|
|
30
|
+
// the same call, as the spec-expansion warnings in build.ts.
|
|
31
|
+
type: 'invalid_openapi_spec',
|
|
32
|
+
// docs.json is where the opt-in lives and what an author edits to change it.
|
|
33
|
+
file: 'docs.json',
|
|
34
|
+
message:
|
|
35
|
+
'auth.jwt.enabled is true but this project has no signing key, so nothing is gated and every page is publicly readable. ' +
|
|
36
|
+
'Generate an Ed25519 signing key under Settings, or set auth.jwt.enabled to false if the site is meant to be public.',
|
|
37
|
+
};
|
|
38
|
+
}
|