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.
Files changed (47) hide show
  1. package/dist/__tests__/unit/turbopack-loader-config-drift.test.d.ts +20 -0
  2. package/dist/__tests__/unit/turbopack-loader-config-drift.test.d.ts.map +1 -0
  3. package/dist/__tests__/unit/turbopack-loader-config-drift.test.js +79 -0
  4. package/dist/__tests__/unit/turbopack-loader-config-drift.test.js.map +1 -0
  5. package/dist/__tests__/unit/vendored-sync.test.js +9 -0
  6. package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
  7. package/dist/lib/deps.js +3 -3
  8. package/dist/lib/deps.js.map +1 -1
  9. package/package.json +5 -5
  10. package/vendored/app/[[...slug]]/page.tsx +1 -102
  11. package/vendored/app/api/jd/auth/logout/route.ts +36 -4
  12. package/vendored/app/api/jd/unlock/route.ts +1 -4
  13. package/vendored/app/layout.tsx +43 -4
  14. package/vendored/components/CodeBlockCopyButton.tsx +7 -2
  15. package/vendored/components/layout/LayoutWrapper.tsx +7 -0
  16. package/vendored/components/mdx/CodeGroup.tsx +161 -13
  17. package/vendored/components/mdx/MDXComponents.tsx +93 -50
  18. package/vendored/components/navigation/Header.tsx +7 -0
  19. package/vendored/components/navigation/LanguageSelector.tsx +35 -0
  20. package/vendored/components/navigation/ThemePreviewPicker.tsx +197 -0
  21. package/vendored/components/ui/CodePanel.tsx +88 -51
  22. package/vendored/components/ui/CodePanelModal.tsx +57 -36
  23. package/vendored/hooks/useWheelScrollChaining.ts +181 -0
  24. package/vendored/lib/auth-plane-write-warning.ts +84 -0
  25. package/vendored/lib/docs-types.ts +12 -0
  26. package/vendored/lib/jwt-key-build-warning.ts +38 -0
  27. package/vendored/lib/language-cookie.ts +20 -0
  28. package/vendored/lib/language-matcher.ts +124 -0
  29. package/vendored/lib/language-utils.ts +103 -0
  30. package/vendored/lib/languages-artifact.ts +160 -0
  31. package/vendored/lib/layout-helpers.tsx +16 -3
  32. package/vendored/lib/mdx-import-scan.ts +110 -0
  33. package/vendored/lib/middleware-helpers.ts +228 -1
  34. package/vendored/lib/page-timestamps.ts +36 -0
  35. package/vendored/lib/rehype-code-meta.ts +74 -9
  36. package/vendored/lib/render-doc-page.tsx +14 -3
  37. package/vendored/lib/revalidation-helpers.ts +3 -0
  38. package/vendored/lib/root-page-slug.ts +9 -4
  39. package/vendored/lib/shiki-transformers.ts +13 -0
  40. package/vendored/lib/static-artifacts.ts +70 -2
  41. package/vendored/lib/theme-preview-context.tsx +39 -0
  42. package/vendored/lib/theme-preview.ts +150 -0
  43. package/vendored/lib/unlock-audit.ts +10 -0
  44. package/vendored/next.config.js +32 -0
  45. package/vendored/schema/docs-schema.json +21 -0
  46. package/vendored/scripts/turbopack-js-to-ts-loader.cjs +51 -0
  47. 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
- if (sync?.selectedLabel) {
139
- const matchIndex = tabLabels.findIndex(
140
- label => label.toLowerCase() === sync.selectedLabel?.toLowerCase()
141
- );
142
- if (matchIndex !== -1 && matchIndex !== activeTab) {
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
- <button
308
- onClick={handleCopy}
309
- className={`p-1.5 rounded-md transition-colors flex-shrink-0 cursor-pointer ${enableFullscreen ? 'ml-1' : ''}`}
310
- style={{ color: codePanelColors.textMuted }}
311
- onMouseEnter={(e) => {
312
- e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
313
- e.currentTarget.style.color = codePanelColors.text;
314
- }}
315
- onMouseLeave={(e) => {
316
- e.currentTarget.style.backgroundColor = 'transparent';
317
- e.currentTarget.style.color = codePanelColors.textMuted;
318
- }}
319
- title="Copy code"
320
- aria-label="Copy code to clipboard"
321
- >
322
- {copied ? (
323
- <i className="fa-solid fa-check text-[14px] text-emerald-500" aria-hidden="true" />
324
- ) : (
325
- <i className="fa-regular fa-copy text-[14px]" aria-hidden="true" />
326
- )}
327
- </button>
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
- <button
436
- onClick={handleCopy}
437
- className={`p-1.5 rounded-md transition-colors flex-shrink-0 cursor-pointer ${enableFullscreen ? 'ml-1' : 'ml-auto'}`}
438
- style={{ color: codePanelColors.textMuted }}
439
- onMouseEnter={(e) => {
440
- e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
441
- e.currentTarget.style.color = codePanelColors.text;
442
- }}
443
- onMouseLeave={(e) => {
444
- e.currentTarget.style.backgroundColor = 'transparent';
445
- e.currentTarget.style.color = codePanelColors.textMuted;
446
- }}
447
- title="Copy code"
448
- aria-label="Copy code to clipboard"
449
- >
450
- {copied ? (
451
- <i className="fa-solid fa-check text-[14px] text-emerald-500" aria-hidden="true" />
452
- ) : (
453
- <i className="fa-regular fa-copy text-[14px]" aria-hidden="true" />
454
- )}
455
- </button>
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({ isOpen, onClose, tabs, title, initialTabIndex = 0 }: CodePanelModalProps) {
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
- <button
228
- onClick={handleCopy}
229
- className="p-2 rounded-md transition-colors cursor-pointer"
230
- style={{ color: codePanelColors.textMuted }}
231
- onMouseEnter={(e) => {
232
- e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
233
- e.currentTarget.style.color = codePanelColors.text;
234
- }}
235
- onMouseLeave={(e) => {
236
- e.currentTarget.style.backgroundColor = 'transparent';
237
- e.currentTarget.style.color = codePanelColors.textMuted;
238
- }}
239
- title="Copy code"
240
- aria-label="Copy code to clipboard"
241
- >
242
- {copied ? (
243
- <i className="fa-solid fa-check text-emerald-500" aria-hidden="true" />
244
- ) : (
245
- <i className="fa-regular fa-copy" aria-hidden="true" />
246
- )}
247
- </button>
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
- <span className="flex items-center gap-1.5">
308
- <kbd
309
- className="px-1.5 py-0.5 rounded text-[10px] font-mono"
310
- style={{
311
- backgroundColor: codePanelColors.tabHoverBg,
312
- border: `0.5px solid ${codePanelColors.border}`,
313
- }}
314
- >
315
- {typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}+C
316
- </kbd>
317
- <span>Copy</span>
318
- </span>
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
+ }