jamdesk 1.1.206 → 1.1.208

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 (34) hide show
  1. package/dist/__tests__/unit/vendored-sync.test.js +9 -0
  2. package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
  3. package/dist/lib/deps.js +3 -3
  4. package/dist/lib/deps.js.map +1 -1
  5. package/package.json +5 -5
  6. package/vendored/app/layout.tsx +43 -4
  7. package/vendored/components/CodeBlockCopyButton.tsx +7 -2
  8. package/vendored/components/layout/LayoutWrapper.tsx +7 -0
  9. package/vendored/components/mdx/CodeGroup.tsx +198 -14
  10. package/vendored/components/mdx/MDXComponents.tsx +93 -50
  11. package/vendored/components/navigation/Header.tsx +7 -0
  12. package/vendored/components/navigation/LanguageSelector.tsx +35 -0
  13. package/vendored/components/navigation/ThemePreviewPicker.tsx +197 -0
  14. package/vendored/components/ui/CodePanel.tsx +88 -51
  15. package/vendored/components/ui/CodePanelModal.tsx +57 -36
  16. package/vendored/hooks/useWheelScrollChaining.ts +181 -0
  17. package/vendored/lib/docs-types.ts +12 -0
  18. package/vendored/lib/language-cookie.ts +20 -0
  19. package/vendored/lib/language-matcher.ts +124 -0
  20. package/vendored/lib/language-utils.ts +103 -0
  21. package/vendored/lib/languages-artifact.ts +160 -0
  22. package/vendored/lib/layout-helpers.tsx +16 -3
  23. package/vendored/lib/middleware-helpers.ts +228 -1
  24. package/vendored/lib/page-timestamps.ts +36 -0
  25. package/vendored/lib/rehype-code-meta.ts +74 -9
  26. package/vendored/lib/render-doc-page.tsx +14 -3
  27. package/vendored/lib/revalidation-helpers.ts +3 -0
  28. package/vendored/lib/root-page-slug.ts +9 -4
  29. package/vendored/lib/shiki-transformers.ts +13 -0
  30. package/vendored/lib/static-artifacts.ts +70 -2
  31. package/vendored/lib/theme-preview-context.tsx +39 -0
  32. package/vendored/lib/theme-preview.ts +150 -0
  33. package/vendored/schema/docs-schema.json +21 -0
  34. package/vendored/workspace-package-lock.json +207 -153
@@ -179,6 +179,98 @@ function isVideoUrl(src: string | undefined): boolean {
179
179
  return VIDEO_EXTENSIONS_IMG.some(ext => pathOnly.toLowerCase().endsWith(ext));
180
180
  }
181
181
 
182
+ /**
183
+ * Renders a fenced code block. Marked with CODE_FENCE_MARKER (a static
184
+ * property, not identity) so CodeGroup.tsx can recognize its own children —
185
+ * compiled MDX always passes the *component function* as `_components.pre`
186
+ * (never the string 'pre'), so CodeGroup's `Children.toArray(children)`
187
+ * sees unresolved elements whose `.type` is this function reference, not
188
+ * its rendered output. Verified against a real compile(): each fence also
189
+ * arrives wrapped in a single-child Fragment, so CodeGroup unwraps one
190
+ * layer before testing the marker.
191
+ *
192
+ * A marker property, not a direct import of this function for `===`
193
+ * comparison, on purpose: MDXComponents already imports CodeGroup (to
194
+ * register it as the <CodeGroup> tag below), so CodeGroup importing
195
+ * MDXComponents back would be circular; and reference equality would be
196
+ * fragile across the dashboard's and CLI's separately vendored copies of
197
+ * this file if either ever mounted more than one instance. Keep this
198
+ * function's marker assignment (just below its definition) and
199
+ * CodeGroup.tsx's CODE_FENCE_MARKER string literal identical — both files
200
+ * are always vendored together as one unit (vendor-builder.js and
201
+ * cli/scripts/vendor.js both copy the whole components/ directory), so they
202
+ * can't drift apart from a partial vendor, only from an un-mirrored edit.
203
+ */
204
+ function MdxPreBlock({
205
+ children,
206
+ 'data-title': dataTitle,
207
+ 'data-nocopy': dataNocopy,
208
+ ...props
209
+ }: HTMLAttributes<HTMLPreElement> & { 'data-title'?: string; 'data-nocopy'?: string }) {
210
+ const language = getCodeLanguage(props, children);
211
+
212
+ // Check for mermaid diagrams - render with Mermaid component
213
+ if (language === 'mermaid') {
214
+ const diagramCode = extractTextFromChildren(children);
215
+ return (
216
+ <>
217
+ <Mermaid>{diagramCode}</Mermaid>
218
+ {/* Crawler / AI fallback: the client-only SVG leaves the server HTML empty.
219
+ Use <noscript>, NOT sr-only: a JS-disabled crawler reads it, while users
220
+ with JS (and screen readers) get the real rendered diagram and never see
221
+ duplicate text. */}
222
+ {diagramCode.trim() && (
223
+ <noscript>
224
+ <pre>{diagramCode}</pre>
225
+ </noscript>
226
+ )}
227
+ </>
228
+ );
229
+ }
230
+
231
+ // Check for HTTP code blocks - render with special styling
232
+ if (language === 'http') {
233
+ const codeText = extractTextFromChildren(children);
234
+ const parsed = parseHttpRequest(codeText);
235
+ if (parsed) {
236
+ return <HttpCodeBlock method={parsed.method} url={parsed.url} />;
237
+ }
238
+ }
239
+
240
+ // If no title, render the pre element directly
241
+ // Copy button is added by CodeBlockCopyButton client component, which
242
+ // reads data-nocopy straight off this DOM node — keep it in place here
243
+ // (unlike the CodePanel path below, where CodePanel's own hideCopy prop
244
+ // does the suppressing instead).
245
+ if (!dataTitle) {
246
+ return (
247
+ <pre {...props} data-nocopy={dataNocopy}>
248
+ {children}
249
+ </pre>
250
+ );
251
+ }
252
+
253
+ // Wrap in CodePanel to show the title. `data-nocopy` is deliberately left
254
+ // off this inner <pre> — CodeBlockCopyButton skips anything inside
255
+ // [data-code-panel] regardless, and CodePanel's own copy button(s) are
256
+ // suppressed via `hideCopy` instead.
257
+ const preElement = <pre {...props}>{children}</pre>;
258
+
259
+ return (
260
+ <CodePanel
261
+ tabs={[{ label: language, content: preElement, language }]}
262
+ title={dataTitle}
263
+ hideTabs={true}
264
+ className="my-6"
265
+ enableFullscreen
266
+ hideCopy={dataNocopy !== undefined}
267
+ />
268
+ );
269
+ }
270
+ // See the doc comment above MdxPreBlock — must match CodeGroup.tsx's
271
+ // CODE_FENCE_MARKER string literal exactly.
272
+ (MdxPreBlock as unknown as { jdCodeFenceMarker?: true }).jdCodeFenceMarker = true;
273
+
182
274
  export const MDXComponents = {
183
275
  Card,
184
276
  // Callout components
@@ -411,56 +503,7 @@ export const MDXComponents = {
411
503
  />
412
504
  ),
413
505
  // Custom pre component to wrap code blocks with titles in CodePanel
414
- pre: ({ children, 'data-title': dataTitle, ...props }: HTMLAttributes<HTMLPreElement> & { 'data-title'?: string }) => {
415
- const language = getCodeLanguage(props, children);
416
-
417
- // Check for mermaid diagrams - render with Mermaid component
418
- if (language === 'mermaid') {
419
- const diagramCode = extractTextFromChildren(children);
420
- return (
421
- <>
422
- <Mermaid>{diagramCode}</Mermaid>
423
- {/* Crawler / AI fallback: the client-only SVG leaves the server HTML empty.
424
- Use <noscript>, NOT sr-only: a JS-disabled crawler reads it, while users
425
- with JS (and screen readers) get the real rendered diagram and never see
426
- duplicate text. */}
427
- {diagramCode.trim() && (
428
- <noscript>
429
- <pre>{diagramCode}</pre>
430
- </noscript>
431
- )}
432
- </>
433
- );
434
- }
435
-
436
- // Check for HTTP code blocks - render with special styling
437
- if (language === 'http') {
438
- const codeText = extractTextFromChildren(children);
439
- const parsed = parseHttpRequest(codeText);
440
- if (parsed) {
441
- return <HttpCodeBlock method={parsed.method} url={parsed.url} />;
442
- }
443
- }
444
-
445
- // If no title, render the pre element directly
446
- // Copy button is added by CodeBlockCopyButton client component
447
- if (!dataTitle) {
448
- return <pre {...props}>{children}</pre>;
449
- }
450
-
451
- // Wrap in CodePanel to show the title
452
- const preElement = <pre {...props}>{children}</pre>;
453
-
454
- return (
455
- <CodePanel
456
- tabs={[{ label: language, content: preElement, language }]}
457
- title={dataTitle}
458
- hideTabs={true}
459
- className="my-6"
460
- enableFullscreen
461
- />
462
- );
463
- },
506
+ pre: MdxPreBlock,
464
507
  // Inline code styling is done via CSS in base.css (.prose :not(pre) > code)
465
508
  table: (props: TableHTMLAttributes<HTMLTableElement>) => (
466
509
  <div className="overflow-x-auto my-6">
@@ -13,6 +13,7 @@ import { ThemeToggle } from '@/components/theme/ThemeToggle';
13
13
  import { LazySearchModal as SearchModal } from '@/components/search/LazySearchModal';
14
14
  import { DefaultLogo } from './DefaultLogo';
15
15
  import { LanguageSelector } from './LanguageSelector';
16
+ import { ThemePreviewPicker } from './ThemePreviewPicker';
16
17
  import LogoutButton from './LogoutButton';
17
18
  import { resolveNavigation } from '@/lib/navigation-resolver';
18
19
  import { getIconClass } from '@/lib/icon-utils';
@@ -555,6 +556,12 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
555
556
  renders nothing until the client confirms an active session. */}
556
557
  {config.auth?.jwt?.enabled === true && <LogoutButton />}
557
558
 
559
+ {/* Theme preview picker — jamdesk-docs only. The component returns
560
+ null unless the ThemePreviewProvider enables it, and carries its
561
+ own `hidden lg:block`, so there is no stray wrapper on customer
562
+ sites. */}
563
+ <ThemePreviewPicker />
564
+
558
565
  {/* Theme toggle - hidden on mobile since it's in the sidebar menu.
559
566
  Also hidden when appearance.strict pins users to the configured mode. */}
560
567
  {!config.appearance?.strict && (
@@ -13,6 +13,7 @@ import {
13
13
  extractLanguageFromPath,
14
14
  toHreflang,
15
15
  } from '@/lib/language-utils';
16
+ import { LANGUAGE_COOKIE } from '@/lib/language-cookie';
16
17
  import { useLinkPrefix } from '@/lib/link-prefix-context';
17
18
  import { useProjectSlug } from '@/lib/project-slug-context';
18
19
  import { getUiStrings } from '@/lib/ui-strings';
@@ -80,6 +81,40 @@ export function LanguageSelector({
80
81
  // Find actual default language from config
81
82
  const actualDefault = displayedLanguages.find((l) => l.isDefault)?.code || defaultLanguage;
82
83
 
84
+ // One-time migration: a preference saved before server-side routing existed
85
+ // lives only in localStorage, so the edge cannot see it and would negotiate
86
+ // over the top of it. Seed the cookie from it once. saveLanguagePreference
87
+ // writes both stores, so this converges after a single page view and is a
88
+ // no-op on every visit after that.
89
+ //
90
+ // Ordering, stated plainly: this effect runs AFTER the page has already
91
+ // loaded, which is AFTER any server-side 307 has already picked a language
92
+ // and pinned its own cookie — no client code can run before a redirect it
93
+ // hasn't been GET to yet. So a visitor with a stale localStorage
94
+ // preference and no cookie can land on one wrong-language page (server
95
+ // negotiated from Accept-Language, disagreeing with their saved choice);
96
+ // this effect then corrects the cookie on that same page. The wrong
97
+ // language is shown exactly once — their NEXT visit to the root reads the
98
+ // corrected cookie (decideLanguageRedirect prioritizes cookie over
99
+ // Accept-Language) and self-heals. See
100
+ // __tests__/lib/language-cookie-self-heal.test.ts. Inherent, not a bug —
101
+ // do not try to make this effect win a race it structurally cannot enter.
102
+ useEffect(() => {
103
+ const saved = getLanguagePreference(projectSlug);
104
+ if (!saved) return;
105
+ let current: string | undefined;
106
+ try {
107
+ current = document.cookie
108
+ .split('; ')
109
+ .find((c) => c.startsWith(`${LANGUAGE_COOKIE}=`))
110
+ ?.split('=')[1];
111
+ } catch {
112
+ return; // cookies unavailable — nothing to migrate into
113
+ }
114
+ if (current !== saved) saveLanguagePreference(saved, projectSlug);
115
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only by design; see comment above
116
+ }, []);
117
+
83
118
  // Check localStorage preference on mount and redirect if needed.
84
119
  // Intentionally mount-only: re-running on pathname/language changes would
85
120
  // fight the user's in-session navigation choices (they may have explicitly
@@ -0,0 +1,197 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { useOnClickOutside } from '@/hooks/useOnClickOutside';
5
+ import { useLinkPrefix } from '@/lib/link-prefix-context';
6
+ import { useThemePreview } from '@/lib/theme-preview-context';
7
+ import { THEME_PREVIEW_COOKIE } from '@/lib/theme-preview';
8
+ import { getAllThemes, type ThemeName } from '@/themes';
9
+
10
+ /**
11
+ * Jamdesk-only affordance: re-skins jamdesk.com/docs with any built-in theme so
12
+ * prospects can see all four on a real docs site.
13
+ *
14
+ * The gate is server-side (lib/theme-preview.ts decides whether the provider is
15
+ * enabled); this component ALSO self-guards on `enabled` so a mistake in
16
+ * Header's conditional can never leak it onto a customer site.
17
+ *
18
+ * Strings are English-only by design. jamdesk-docs has fr/es locales, but the
19
+ * only prose here is "Reset to default" and "Default" — everything else is a
20
+ * proper noun — and adding entries to lib/ui-strings.ts for a sales affordance
21
+ * isn't worth the churn across every locale.
22
+ */
23
+ export function ThemePreviewPicker() {
24
+ const { enabled, activeTheme, defaultTheme } = useThemePreview();
25
+ const linkPrefix = useLinkPrefix();
26
+ const [isOpen, setIsOpen] = useState(false);
27
+ const containerRef = useRef<HTMLDivElement>(null);
28
+ const buttonRef = useRef<HTMLButtonElement>(null);
29
+ const listRef = useRef<HTMLUListElement>(null);
30
+
31
+ // Every close path but a theme selection runs through here. A click-outside
32
+ // would otherwise unmount the focused option and drop focus to <body>, so
33
+ // the visitor's next Tab restarts at the top of the document. Synchronous on
34
+ // purpose: this runs during the mousedown dispatch, so a click that lands on
35
+ // something focusable still ends up there — the browser's own focus default
36
+ // wins afterwards, which is what it should do.
37
+ function closeAndRestoreFocus() {
38
+ const hadFocus = !!containerRef.current?.contains(document.activeElement);
39
+ setIsOpen(false);
40
+ if (hadFocus) buttonRef.current?.focus();
41
+ }
42
+
43
+ useOnClickOutside(containerRef, closeAndRestoreFocus, isOpen);
44
+
45
+ // Move focus onto the active option when the menu opens. Without this the
46
+ // trigger keeps focus and Tab is the only way forward — and an earlier cut of
47
+ // this component closed the menu on Tab (copied from LanguageSelector, where
48
+ // that is safe only because ArrowUp/Down have already moved focus into the
49
+ // list), which left the widget keyboard-inoperable: WCAG 2.1.1.
50
+ useEffect(() => {
51
+ if (!isOpen) return;
52
+ const options = listRef.current?.querySelectorAll<HTMLElement>('[role="option"]');
53
+ if (!options?.length) return;
54
+ const active = Array.from(options).find((o) => o.getAttribute('aria-selected') === 'true');
55
+ (active ?? options[0]).focus();
56
+ }, [isOpen]);
57
+
58
+ if (!enabled) return null;
59
+
60
+ // Escape only. Arrow-key roving focus is deliberately NOT copied from
61
+ // LanguageSelector: its options are a customer-configurable list, whereas
62
+ // this is a fixed four-row Jamdesk-only affordance whose rows are ordinary
63
+ // buttons that Tab already walks. Escape must stay — it is the way back out
64
+ // of a menu focus has just been moved into.
65
+ function handleKeyDown(e: React.KeyboardEvent) {
66
+ if (e.key === 'Escape') closeAndRestoreFocus();
67
+ }
68
+
69
+ // Tabbing past the last row leaves the widget; close behind it. This is what
70
+ // LanguageSelector's Tab branch achieved, without eating the Tab that is this
71
+ // component's only way INTO the option list.
72
+ function handleFocusOut(e: React.FocusEvent) {
73
+ // A null relatedTarget means focus landed on <body>, NOT that focus left
74
+ // the widget — and since the menu always opens with focus inside itself,
75
+ // that blur comes from inside. Safari and Firefox-on-macOS produce it on
76
+ // every mousedown over a <button>; Chrome produces it for a mousedown on
77
+ // this panel's own padding or its divider band. Closing there unmounts the
78
+ // row before its click can land, so no theme is ever applied. Do not
79
+ // "simplify" this branch away: genuine outside-pointer closes are
80
+ // useOnClickOutside's job, and Escape and Tab both supply a real
81
+ // relatedTarget. The only thing given up is close-on-window-blur.
82
+ if (!(e.relatedTarget instanceof Element)) return;
83
+ if (!e.currentTarget.contains(e.relatedTarget)) setIsOpen(false);
84
+ }
85
+
86
+ // `null` clears the cookie. Path mirrors the docs prefix so the cookie is
87
+ // scoped to /docs on jamdesk.com and to / on jamdesk-docs.jamdesk.app.
88
+ function applyTheme(name: ThemeName | null) {
89
+ const path = linkPrefix || '/';
90
+ // SESSION cookie on the set path — no Max-Age, so the preview dies with the
91
+ // browser. The picker is desktop-only (`hidden lg:block`), so a persisted
92
+ // preview would follow a visitor to mobile with no affordance to reset it.
93
+ // The CLEAR path is the opposite and must stay explicit: `Max-Age=0` is
94
+ // what expires an existing cookie, and omitting it there would leave the
95
+ // preview in place.
96
+ const expiry = name === null ? '; Max-Age=0' : '';
97
+ // `Secure` means the browser silently DROPS this cookie over plain http, so
98
+ // the picker looks inert when QA'd on http://localhost — use https or a
99
+ // deployed host.
100
+ document.cookie =
101
+ `${THEME_PREVIEW_COOKIE}=${name ?? ''}; Path=${path}${expiry}; SameSite=Lax; Secure`;
102
+ // Close first: the reload is not instant, and an open menu left showing the
103
+ // previous selection reads as a dead click.
104
+ setIsOpen(false);
105
+ // Full reload — the theme is applied by the server render (CSS variables,
106
+ // font class and layout variant all key off config.theme), so there is no
107
+ // client-side state to update.
108
+ window.location.reload();
109
+ }
110
+
111
+ const allThemes = getAllThemes();
112
+ const activeDisplayName = allThemes.find((t) => t.name === activeTheme)?.displayName ?? '';
113
+
114
+ return (
115
+ <div
116
+ ref={containerRef}
117
+ className="relative hidden lg:block"
118
+ onKeyDown={handleKeyDown}
119
+ onBlur={handleFocusOut}
120
+ >
121
+ <button
122
+ ref={buttonRef}
123
+ onClick={() => {
124
+ setIsOpen(!isOpen);
125
+ // Closing unmounts the focused option. Safari does not focus a
126
+ // button on mousedown, so without this focus would land on <body>.
127
+ // Mirrors Prompt.tsx's trigger.
128
+ if (isOpen) buttonRef.current?.focus();
129
+ }}
130
+ aria-expanded={isOpen}
131
+ aria-haspopup="listbox"
132
+ aria-label={`Preview a theme: ${activeDisplayName}`}
133
+ className="flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer transition-colors text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-tertiary)]"
134
+ >
135
+ <i
136
+ className="fa-solid fa-palette text-[12px] text-[var(--color-text-tertiary)]"
137
+ aria-hidden="true"
138
+ />
139
+ <i
140
+ className={`fa-solid fa-chevron-down text-[10px] text-[var(--color-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`}
141
+ aria-hidden="true"
142
+ />
143
+ </button>
144
+
145
+ {isOpen && (
146
+ <div
147
+ className="absolute right-0 top-full mt-1 z-50 min-w-[190px] py-1 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-lg"
148
+ style={{ boxShadow: 'var(--shadow-lg)' }}
149
+ >
150
+ <ul ref={listRef} role="listbox" aria-label="Preview a theme">
151
+ {allThemes.map((theme) => {
152
+ const isActive = theme.name === activeTheme;
153
+ return (
154
+ <li key={theme.name}>
155
+ <button
156
+ role="option"
157
+ aria-selected={isActive}
158
+ onClick={() => applyTheme(theme.name as ThemeName)}
159
+ className="w-full flex items-center justify-between gap-3 px-3 py-2 text-left text-sm cursor-pointer text-[var(--color-text-primary)] hover:bg-[var(--color-bg-tertiary)]"
160
+ >
161
+ <span>{theme.displayName}</span>
162
+ <span className="flex items-center gap-2">
163
+ {theme.name === defaultTheme && (
164
+ <span className="text-xs text-[var(--color-text-tertiary)]">Default</span>
165
+ )}
166
+ {isActive && (
167
+ <i
168
+ className="fa-solid fa-check text-[11px] text-[var(--color-accent)]"
169
+ aria-hidden="true"
170
+ />
171
+ )}
172
+ </span>
173
+ </button>
174
+ </li>
175
+ );
176
+ })}
177
+ </ul>
178
+
179
+ {/* Outside the <ul>: a `role="listbox"` may only own `option` and
180
+ `group` children, and a reset action is neither. Nesting it as a
181
+ bare <li><button> would put a `listitem` inside a listbox, which
182
+ screen readers drop or mis-announce. LanguageSelector — the
183
+ pattern this component mirrors — has no extra row, so there is no
184
+ existing precedent to copy here. */}
185
+ <div className="mt-1 pt-1 border-t border-[var(--color-border)]">
186
+ <button
187
+ onClick={() => applyTheme(null)}
188
+ className="w-full px-3 py-2 text-left text-sm cursor-pointer text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-tertiary)]"
189
+ >
190
+ Reset to default
191
+ </button>
192
+ </div>
193
+ </div>
194
+ )}
195
+ </div>
196
+ );
197
+ }
@@ -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>