@redocly/theme 0.69.0-next.10 → 0.69.0-next.11

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 (28) hide show
  1. package/lib/components/MCP/McpConnectionTabs.js +21 -11
  2. package/lib/core/contexts/AccordionGroupContext.d.ts +1 -0
  3. package/lib/core/contexts/AccordionGroupContext.js +6 -0
  4. package/lib/core/contexts/index.d.ts +1 -0
  5. package/lib/core/contexts/index.js +1 -0
  6. package/lib/core/hooks/code-walkthrough/use-code-walkthrough-steps.js +1 -0
  7. package/lib/core/hooks/use-active-heading.js +4 -0
  8. package/lib/core/hooks/use-connect-mcp-button.js +7 -2
  9. package/lib/core/hooks/use-tabs.js +4 -2
  10. package/lib/core/hooks/use-telemetry-fallback.d.ts +2 -0
  11. package/lib/core/hooks/use-telemetry-fallback.js +2 -0
  12. package/lib/markdoc/components/Accordion/Accordion.js +6 -6
  13. package/lib/markdoc/components/Accordion/AccordionGroup.d.ts +2 -1
  14. package/lib/markdoc/components/Accordion/AccordionGroup.js +39 -3
  15. package/lib/markdoc/tags/accordion-group.js +3 -0
  16. package/package.json +2 -2
  17. package/src/components/MCP/McpConnectionTabs.tsx +32 -10
  18. package/src/core/contexts/AccordionGroupContext.tsx +3 -0
  19. package/src/core/contexts/index.ts +1 -0
  20. package/src/core/hooks/__mocks__/use-theme-hooks.ts +2 -0
  21. package/src/core/hooks/code-walkthrough/use-code-walkthrough-steps.ts +1 -0
  22. package/src/core/hooks/use-active-heading.ts +4 -0
  23. package/src/core/hooks/use-connect-mcp-button.ts +8 -2
  24. package/src/core/hooks/use-tabs.ts +4 -2
  25. package/src/core/hooks/use-telemetry-fallback.ts +2 -0
  26. package/src/markdoc/components/Accordion/Accordion.tsx +8 -2
  27. package/src/markdoc/components/Accordion/AccordionGroup.tsx +9 -2
  28. package/src/markdoc/tags/accordion-group.ts +3 -0
@@ -62,11 +62,10 @@ const TAB_LABELS = {
62
62
  },
63
63
  json: { translationKey: 'page.mcpNotAllowed.tabs.json', defaultLabel: 'JSON' },
64
64
  };
65
- // The report control is docs feedback, not useful on the connection page.
66
- const CODE_BLOCK_HEADER = { controls: { copy: {}, report: { hidden: true } } };
67
65
  function McpConnectionTabs({ options = DEFAULT_TAB_OPTIONS, className, }) {
68
- const { useTranslate } = (0, hooks_1.useThemeHooks)();
66
+ const { useTranslate, useTelemetry } = (0, hooks_1.useThemeHooks)();
69
67
  const { translate } = useTranslate();
68
+ const telemetry = useTelemetry();
70
69
  const { serverName, serverUrl, isMcpDisabled } = (0, hooks_1.useMCPConfig)();
71
70
  // The SSR markup of error pages is cached with the build-time origin, so the
72
71
  // tabs remount after hydration to pick up the real browser origin.
@@ -76,20 +75,31 @@ function McpConnectionTabs({ options = DEFAULT_TAB_OPTIONS, className, }) {
76
75
  return null;
77
76
  }
78
77
  const connection = { serverName, url: serverUrl };
78
+ const snippetHeader = (client, snippet) => ({
79
+ controls: {
80
+ copy: {
81
+ onClick: () => telemetry.sendMcpConnectionSnippetCopiedMessage([
82
+ Object.assign(Object.assign({}, (0, utils_1.getBaseDataAttributes)('mcpConnectionSnippet', 'button')), { client, snippet }),
83
+ ]),
84
+ },
85
+ // The report control is docs feedback, not useful on the connection page.
86
+ report: { hidden: true },
87
+ },
88
+ });
79
89
  return (react_1.default.createElement(McpConnectionTabsWrapper, { key: hydrated ? 'client' : 'ssr', "data-component-name": "MCP/McpConnectionTabs", "data-testid": "mcp-connection-tabs", className: className },
80
90
  react_1.default.createElement(Tabs_1.Tabs, { size: Tabs_1.TabsSize.SMALL }, options.map((option) => {
81
91
  const { translationKey, defaultLabel, icon: TabIcon } = TAB_LABELS[option];
82
- return (react_1.default.createElement(TabItemFragment_1.TabItemFragment, { key: option, label: translate(translationKey, defaultLabel), icon: TabIcon ? react_1.default.createElement(TabIcon, null) : undefined }, renderTabContent(option, connection, translate)));
92
+ return (react_1.default.createElement(TabItemFragment_1.TabItemFragment, { key: option, label: translate(translationKey, defaultLabel), icon: TabIcon ? react_1.default.createElement(TabIcon, null) : undefined }, renderTabContent(option, connection, translate, snippetHeader)));
83
93
  }))));
84
94
  }
85
- function renderTabContent(option, connection, translate) {
95
+ function renderTabContent(option, connection, translate, snippetHeader) {
86
96
  const terminalHint = (react_1.default.createElement(TabHint, null, translate('page.mcpNotAllowed.snippets.terminal.hint', 'Run this command in your terminal:')));
87
- const urlBlock = react_1.default.createElement(CodeBlock_1.CodeBlock, { lang: "text", source: connection.url, header: CODE_BLOCK_HEADER });
97
+ const urlBlock = (react_1.default.createElement(CodeBlock_1.CodeBlock, { lang: "text", source: connection.url, header: snippetHeader(option, 'chatgpt-app') }));
88
98
  const assistantPromptClient = {
89
99
  headline: translate('page.mcpNotAllowed.snippets.prompt.headline', 'Prompt'),
90
100
  content: (react_1.default.createElement(react_1.default.Fragment, null,
91
101
  react_1.default.createElement(TabHint, null, translate('page.mcpNotAllowed.snippets.prompt.hint', 'Or paste this prompt into the chat to have it add the server for you:')),
92
- react_1.default.createElement(CodeBlock_1.CodeBlock, { lang: "text", source: (0, utils_1.generateMcpInstallPrompt)(connection), header: CODE_BLOCK_HEADER, wrapContents: true }))),
102
+ react_1.default.createElement(CodeBlock_1.CodeBlock, { lang: "text", source: (0, utils_1.generateMcpInstallPrompt)(connection), header: snippetHeader(option, 'prompt'), wrapContents: true }))),
93
103
  };
94
104
  switch (option) {
95
105
  case 'claude':
@@ -98,13 +108,13 @@ function renderTabContent(option, connection, translate) {
98
108
  headline: translate('page.mcpNotAllowed.snippets.claudeCode.headline', 'Claude Code'),
99
109
  content: (react_1.default.createElement(react_1.default.Fragment, null,
100
110
  terminalHint,
101
- react_1.default.createElement(CodeBlock_1.CodeBlock, { lang: "shell", source: (0, utils_1.generateClaudeCodeCommand)(connection), header: CODE_BLOCK_HEADER, wrapContents: true }))),
111
+ react_1.default.createElement(CodeBlock_1.CodeBlock, { lang: "shell", source: (0, utils_1.generateClaudeCodeCommand)(connection), header: snippetHeader(option, 'claude-code'), wrapContents: true }))),
102
112
  },
103
113
  {
104
114
  headline: translate('page.mcpNotAllowed.snippets.claudeDesktop.headline', 'Claude Desktop'),
105
115
  content: (react_1.default.createElement(react_1.default.Fragment, null,
106
116
  react_1.default.createElement(TabHint, null, translate('page.mcpNotAllowed.snippets.claudeDesktop.instruction', 'In Claude Desktop, open Settings → Developer → Edit Config, add this to the configuration file, and restart Claude Desktop:')),
107
- react_1.default.createElement(CodeBlock_1.CodeBlock, { lang: "json", source: (0, utils_1.generateClaudeDesktopConfig)(connection), header: CODE_BLOCK_HEADER }))),
117
+ react_1.default.createElement(CodeBlock_1.CodeBlock, { lang: "json", source: (0, utils_1.generateClaudeDesktopConfig)(connection), header: snippetHeader(option, 'claude-desktop') }))),
108
118
  },
109
119
  ]);
110
120
  case 'chatgpt':
@@ -113,7 +123,7 @@ function renderTabContent(option, connection, translate) {
113
123
  headline: translate('page.mcpNotAllowed.snippets.codexCli.headline', 'Codex CLI'),
114
124
  content: (react_1.default.createElement(react_1.default.Fragment, null,
115
125
  terminalHint,
116
- react_1.default.createElement(CodeBlock_1.CodeBlock, { lang: "shell", source: (0, utils_1.generateCodexCommand)(connection), header: CODE_BLOCK_HEADER, wrapContents: true }))),
126
+ react_1.default.createElement(CodeBlock_1.CodeBlock, { lang: "shell", source: (0, utils_1.generateCodexCommand)(connection), header: snippetHeader(option, 'codex'), wrapContents: true }))),
117
127
  },
118
128
  {
119
129
  headline: translate('page.mcpNotAllowed.snippets.chatgptApp.headline', 'ChatGPT desktop app'),
@@ -126,7 +136,7 @@ function renderTabContent(option, connection, translate) {
126
136
  case 'json':
127
137
  return (react_1.default.createElement(react_1.default.Fragment, null,
128
138
  react_1.default.createElement(TabHint, null, translate('page.mcpNotAllowed.snippets.json.hint', 'Add this server entry to your MCP client configuration file:')),
129
- react_1.default.createElement(CodeBlock_1.CodeBlock, { lang: "json", source: JSON.stringify({ [connection.serverName]: { url: connection.url, description: 'MCP Server' } }, null, 2), header: CODE_BLOCK_HEADER })));
139
+ react_1.default.createElement(CodeBlock_1.CodeBlock, { lang: "json", source: JSON.stringify({ [connection.serverName]: { url: connection.url, description: 'MCP Server' } }, null, 2), header: snippetHeader(option, 'json') })));
130
140
  }
131
141
  }
132
142
  function renderClientList(clients) {
@@ -0,0 +1 @@
1
+ export declare const AccordionGroupContext: import("react").Context<string | null>;
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AccordionGroupContext = void 0;
4
+ const react_1 = require("react");
5
+ exports.AccordionGroupContext = (0, react_1.createContext)(null);
6
+ //# sourceMappingURL=AccordionGroupContext.js.map
@@ -3,6 +3,7 @@ export * from './MarkdownLinkContext';
3
3
  export * from './PageActionsContext';
4
4
  export * from './CodeWalkthrough/CodeWalkthroughControlsContext';
5
5
  export * from './CodeWalkthrough/CodeWalkthroughStepsContext';
6
+ export * from './AccordionGroupContext';
6
7
  export * from './CodeSnippetContext';
7
8
  export * from './SearchContext';
8
9
  export * from './Toast/ToastContext';
@@ -19,6 +19,7 @@ __exportStar(require("./MarkdownLinkContext"), exports);
19
19
  __exportStar(require("./PageActionsContext"), exports);
20
20
  __exportStar(require("./CodeWalkthrough/CodeWalkthroughControlsContext"), exports);
21
21
  __exportStar(require("./CodeWalkthrough/CodeWalkthroughStepsContext"), exports);
22
+ __exportStar(require("./AccordionGroupContext"), exports);
22
23
  __exportStar(require("./CodeSnippetContext"), exports);
23
24
  __exportStar(require("./SearchContext"), exports);
24
25
  __exportStar(require("./Toast/ToastContext"), exports);
@@ -58,6 +58,7 @@ function useCodeWalkthroughSteps({ steps, enableDeepLink, root, }) {
58
58
  startOffset: markersMinTopOffset < endOffset ? startOffset : 0,
59
59
  });
60
60
  }
61
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates -- marker geometry is derived from post-layout measurements (offsetTop, clientHeight, window.innerHeight)
61
62
  setMarkers(markers.reduce((acc, marker, index) => {
62
63
  var _a;
63
64
  const step = visibleSteps[index];
@@ -205,19 +205,23 @@ function useActiveHeading(contentElement, displayedHeadings) {
205
205
  return;
206
206
  }
207
207
  const headers = findHeaders(contentElement);
208
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates -- headings and the initial active heading come from DOM queries and scroll measurement, available only after layout
208
209
  setHeadingElements(headers);
209
210
  if (headers.length > 0 && !heading) {
210
211
  if (isTopOfPage()) {
212
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates -- see above
211
213
  setHeading(headers[0].id);
212
214
  }
213
215
  else {
214
216
  const totalHeight = scrollY + window.innerHeight;
215
217
  if (totalHeight >= document.body.scrollHeight - 10) {
218
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates -- see above
216
219
  setHeading(((_a = headers[headers.length - 1]) === null || _a === void 0 ? void 0 : _a.id) || undefined);
217
220
  }
218
221
  else {
219
222
  const triggerOffset = getTriggerOffset();
220
223
  const activeHeader = findActiveHeaderByVirtualPosition(headers, scrollY, triggerOffset);
224
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates -- see above
221
225
  setHeading(activeHeader.id);
222
226
  }
223
227
  }
@@ -11,15 +11,20 @@ const use_mcp_config_1 = require("./use-mcp-config");
11
11
  const clipboard_service_1 = require("../utils/clipboard-service");
12
12
  const mcp_1 = require("../constants/mcp");
13
13
  const mcp_2 = require("../utils/mcp");
14
+ const get_base_data_attributes_1 = require("../utils/telemetry/get-base-data-attributes");
14
15
  const COPIED_RESET_TIMEOUT = 1000;
15
16
  function useConnectMCPButton({ options = mcp_1.DEFAULT_MCP_OPTIONS, } = {}) {
16
- const { useTranslate } = (0, use_theme_hooks_1.useThemeHooks)();
17
+ const { useTranslate, useTelemetry } = (0, use_theme_hooks_1.useThemeHooks)();
17
18
  const { translate } = useTranslate();
19
+ const telemetry = useTelemetry();
18
20
  const { serverName, serverUrl, cursorUrl, vscodeUrl } = (0, use_mcp_config_1.useMCPConfig)();
19
21
  const [copiedOption, setCopiedOption] = (0, react_1.useState)(null);
20
22
  // eslint-disable-next-line react-hooks/exhaustive-deps
21
23
  const resetCopied = (0, react_1.useCallback)((0, lodash_debounce_1.default)(() => setCopiedOption(null), COPIED_RESET_TIMEOUT), []);
22
24
  const handleAction = (0, react_1.useCallback)((action) => {
25
+ telemetry.sendConnectMcpClickedMessage([
26
+ Object.assign(Object.assign({}, (0, get_base_data_attributes_1.getBaseDataAttributes)('connectMcp', 'button')), { option: action }),
27
+ ]);
23
28
  const connection = { serverName, url: serverUrl };
24
29
  const copyTextMap = {
25
30
  copy: JSON.stringify({ [serverName]: { url: serverUrl, description: 'MCP Server' } }, null, 2),
@@ -38,7 +43,7 @@ function useConnectMCPButton({ options = mcp_1.DEFAULT_MCP_OPTIONS, } = {}) {
38
43
  vscode: vscodeUrl,
39
44
  };
40
45
  window.open(urlMap[action], '_blank');
41
- }, [cursorUrl, vscodeUrl, serverUrl, serverName, resetCopied]);
46
+ }, [cursorUrl, vscodeUrl, serverUrl, serverName, resetCopied, telemetry]);
42
47
  const triggerButtonText = (0, react_1.useMemo)(() => translate('page.actions.connectMcp', 'Connect MCP'), [translate]);
43
48
  const visibleOptions = (0, react_1.useMemo)(() => options.filter(Boolean), [options]);
44
49
  return {
@@ -220,12 +220,14 @@ const useActiveTab = ({ initialTab, tabsId }) => {
220
220
  const [activeTab, setActiveTab] = (0, react_1.useState)(undefined);
221
221
  const resolvedActiveTab = activeTab !== null && activeTab !== void 0 ? activeTab : initialTab;
222
222
  const prevActiveTabRef = (0, react_1.useRef)(resolvedActiveTab);
223
+ const isTabResolvedFromHashRef = (0, react_1.useRef)(false);
223
224
  (0, react_1.useEffect)(() => {
224
- if (activeTab !== undefined) {
225
+ if (isTabResolvedFromHashRef.current) {
225
226
  return;
226
227
  }
228
+ isTabResolvedFromHashRef.current = true;
227
229
  setActiveTab(getInitialTab({ initialTab, hash: location.hash, tabsId }));
228
- }, [activeTab, initialTab, location.hash, tabsId]);
230
+ }, [initialTab, location.hash, tabsId]);
229
231
  (0, react_1.useEffect)(() => {
230
232
  const hasActiveTabChanged = prevActiveTabRef.current !== resolvedActiveTab;
231
233
  if (!tabsId || !hasActiveTabChanged) {
@@ -34,6 +34,8 @@ export declare const useTelemetryFallback: () => {
34
34
  sendProductPickerChangedMessage: () => void;
35
35
  sendLogoClickedMessage: () => void;
36
36
  sendCodeSnippetReportedMessage: () => void;
37
+ sendConnectMcpClickedMessage: () => void;
38
+ sendMcpConnectionSnippetCopiedMessage: () => void;
37
39
  sendMobileMenuClosedMessage: () => void;
38
40
  sendMobileMenuOpenedMessage: () => void;
39
41
  sendPageViewedMessage: () => void;
@@ -39,6 +39,8 @@ const useTelemetryFallback = () => ({
39
39
  sendProductPickerChangedMessage: () => { },
40
40
  sendLogoClickedMessage: () => { },
41
41
  sendCodeSnippetReportedMessage: () => { },
42
+ sendConnectMcpClickedMessage: () => { },
43
+ sendMcpConnectionSnippetCopiedMessage: () => { },
42
44
  sendMobileMenuClosedMessage: () => { },
43
45
  sendMobileMenuOpenedMessage: () => { },
44
46
  sendPageViewedMessage: () => { },
@@ -32,21 +32,21 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
- var __importDefault = (this && this.__importDefault) || function (mod) {
36
- return (mod && mod.__esModule) ? mod : { "default": mod };
37
- };
38
35
  Object.defineProperty(exports, "__esModule", { value: true });
39
36
  exports.Accordion = Accordion;
40
- const react_1 = __importDefault(require("react"));
37
+ const react_1 = __importStar(require("react"));
41
38
  const styled_components_1 = __importStar(require("styled-components"));
42
39
  const ChevronRightIcon_1 = require("../../../icons/ChevronRightIcon/ChevronRightIcon");
43
40
  const constants_1 = require("../../../core/constants");
41
+ const contexts_1 = require("../../../core/contexts");
44
42
  function Accordion({ title, expanded = false, className, children, 'data-source': dataSource, 'data-hash': dataHash, }) {
45
- return (react_1.default.createElement(AccordionWrapper, { className: className, open: expanded, "data-component-name": constants_1.ACCORDION_COMPONENT_NAME, "data-source": dataSource, "data-hash": dataHash },
43
+ const groupName = (0, react_1.useContext)(contexts_1.AccordionGroupContext);
44
+ return (react_1.default.createElement(AccordionWrapper, { className: className, open: expanded, name: groupName !== null && groupName !== void 0 ? groupName : undefined, "data-component-name": constants_1.ACCORDION_COMPONENT_NAME, "data-source": dataSource, "data-hash": dataHash },
46
45
  react_1.default.createElement(AccordionSummary, null,
47
46
  react_1.default.createElement(ChevronRightIcon_1.ChevronRightIcon, { color: "--md-accordion-chevron-icon-color", size: "var(--md-accordion-chevron-icon-size)" }),
48
47
  title),
49
- react_1.default.createElement(AccordionBody, null, children)));
48
+ react_1.default.createElement(AccordionBody, null,
49
+ react_1.default.createElement(contexts_1.AccordionGroupContext.Provider, { value: null }, children))));
50
50
  }
51
51
  const AccordionWrapper = styled_components_1.default.details `
52
52
  margin: var(--md-accordion-margin);
@@ -1,5 +1,6 @@
1
1
  import type { JSX, PropsWithChildren } from 'react';
2
2
  export type AccordionGroupProps = {
3
+ singleExpanded?: boolean;
3
4
  className?: string;
4
5
  };
5
- export declare function AccordionGroup({ className, children, }: PropsWithChildren<AccordionGroupProps>): JSX.Element;
6
+ export declare function AccordionGroup({ singleExpanded, className, children, }: PropsWithChildren<AccordionGroupProps>): JSX.Element;
@@ -1,14 +1,50 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
5
38
  Object.defineProperty(exports, "__esModule", { value: true });
6
39
  exports.AccordionGroup = AccordionGroup;
7
- const react_1 = __importDefault(require("react"));
40
+ const react_1 = __importStar(require("react"));
8
41
  const styled_components_1 = __importDefault(require("styled-components"));
9
42
  const constants_1 = require("../../../core/constants");
10
- function AccordionGroup({ className, children, }) {
11
- return (react_1.default.createElement(AccordionGroupWrapper, { className: className, "data-component-name": constants_1.ACCORDION_GROUP_COMPONENT_NAME }, children));
43
+ const contexts_1 = require("../../../core/contexts");
44
+ function AccordionGroup({ singleExpanded = false, className, children, }) {
45
+ const groupName = (0, react_1.useId)();
46
+ return (react_1.default.createElement(AccordionGroupWrapper, { className: className, "data-component-name": constants_1.ACCORDION_GROUP_COMPONENT_NAME },
47
+ react_1.default.createElement(contexts_1.AccordionGroupContext.Provider, { value: singleExpanded ? groupName : null }, children)));
12
48
  }
13
49
  const AccordionGroupWrapper = styled_components_1.default.div `
14
50
  margin: var(--md-accordion-group-margin);
@@ -4,6 +4,9 @@ exports.accordionGroup = void 0;
4
4
  exports.accordionGroup = {
5
5
  schema: {
6
6
  render: 'AccordionGroup',
7
+ attributes: {
8
+ singleExpanded: { type: Boolean, default: false },
9
+ },
7
10
  },
8
11
  tagName: 'accordion-group',
9
12
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redocly/theme",
3
- "version": "0.69.0-next.10",
3
+ "version": "0.69.0-next.11",
4
4
  "description": "Shared UI components lib",
5
5
  "keywords": [
6
6
  "theme",
@@ -42,7 +42,7 @@
42
42
  "devDependencies": {
43
43
  "@babel/core": "7.29.7",
44
44
  "@markdoc/markdoc": "0.5.2",
45
- "@redocly/realm-asyncapi-sdk": "0.15.0-next.3",
45
+ "@redocly/realm-asyncapi-sdk": "0.15.0-next.4",
46
46
  "@shikijs/types": "3.21.0",
47
47
  "@testing-library/react": "16.3.0",
48
48
  "@testing-library/user-event": "14.6.1",
@@ -3,6 +3,8 @@ import styled from 'styled-components';
3
3
 
4
4
  import type { JSX, ReactNode } from 'react';
5
5
  import type { TFunction } from '@redocly/theme/core/types';
6
+ import type { AsyncApiRealmUI } from '@redocly/realm-asyncapi-sdk';
7
+ import type { CodeBlockControlsProps } from '@redocly/theme/components/CodeBlock/CodeBlockControls';
6
8
 
7
9
  import { useMCPConfig, useThemeHooks } from '@redocly/theme/core/hooks';
8
10
  import {
@@ -10,6 +12,7 @@ import {
10
12
  generateClaudeDesktopConfig,
11
13
  generateCodexCommand,
12
14
  generateMcpInstallPrompt,
15
+ getBaseDataAttributes,
13
16
  } from '@redocly/theme/core/utils';
14
17
  import { CodeBlock } from '@redocly/theme/components/CodeBlock/CodeBlock';
15
18
  import { ClaudeIcon } from '@redocly/theme/icons/ClaudeIcon/ClaudeIcon';
@@ -47,15 +50,18 @@ const TAB_LABELS: Record<
47
50
 
48
51
  type TabClient = { headline: string; content: ReactNode };
49
52
 
50
- // The report control is docs feedback, not useful on the connection page.
51
- const CODE_BLOCK_HEADER = { controls: { copy: {}, report: { hidden: true } } };
53
+ type SnippetId =
54
+ AsyncApiRealmUI.EventPayload<'com.redocly.mcpConnectionSnippet.copied'>[0]['snippet'];
55
+
56
+ type SnippetHeader = (client: McpConnectionTab, snippet: SnippetId) => CodeBlockControlsProps;
52
57
 
53
58
  export function McpConnectionTabs({
54
59
  options = DEFAULT_TAB_OPTIONS,
55
60
  className,
56
61
  }: McpConnectionTabsProps): JSX.Element | null {
57
- const { useTranslate } = useThemeHooks();
62
+ const { useTranslate, useTelemetry } = useThemeHooks();
58
63
  const { translate } = useTranslate();
64
+ const telemetry = useTelemetry();
59
65
  const { serverName, serverUrl, isMcpDisabled } = useMCPConfig();
60
66
  // The SSR markup of error pages is cached with the build-time origin, so the
61
67
  // tabs remount after hydration to pick up the real browser origin.
@@ -69,6 +75,19 @@ export function McpConnectionTabs({
69
75
 
70
76
  const connection = { serverName, url: serverUrl };
71
77
 
78
+ const snippetHeader: SnippetHeader = (client, snippet) => ({
79
+ controls: {
80
+ copy: {
81
+ onClick: () =>
82
+ telemetry.sendMcpConnectionSnippetCopiedMessage([
83
+ { ...getBaseDataAttributes('mcpConnectionSnippet', 'button'), client, snippet },
84
+ ]),
85
+ },
86
+ // The report control is docs feedback, not useful on the connection page.
87
+ report: { hidden: true },
88
+ },
89
+ });
90
+
72
91
  return (
73
92
  <McpConnectionTabsWrapper
74
93
  key={hydrated ? 'client' : 'ssr'}
@@ -86,7 +105,7 @@ export function McpConnectionTabs({
86
105
  label={translate(translationKey, defaultLabel)}
87
106
  icon={TabIcon ? <TabIcon /> : undefined}
88
107
  >
89
- {renderTabContent(option, connection, translate)}
108
+ {renderTabContent(option, connection, translate, snippetHeader)}
90
109
  </TabItemFragment>
91
110
  );
92
111
  })}
@@ -99,6 +118,7 @@ function renderTabContent(
99
118
  option: McpConnectionTab,
100
119
  connection: { serverName: string; url: string },
101
120
  translate: TFunction,
121
+ snippetHeader: SnippetHeader,
102
122
  ): ReactNode {
103
123
  const terminalHint = (
104
124
  <TabHint>
@@ -106,7 +126,9 @@ function renderTabContent(
106
126
  </TabHint>
107
127
  );
108
128
 
109
- const urlBlock = <CodeBlock lang="text" source={connection.url} header={CODE_BLOCK_HEADER} />;
129
+ const urlBlock = (
130
+ <CodeBlock lang="text" source={connection.url} header={snippetHeader(option, 'chatgpt-app')} />
131
+ );
110
132
 
111
133
  const assistantPromptClient: TabClient = {
112
134
  headline: translate('page.mcpNotAllowed.snippets.prompt.headline', 'Prompt'),
@@ -121,7 +143,7 @@ function renderTabContent(
121
143
  <CodeBlock
122
144
  lang="text"
123
145
  source={generateMcpInstallPrompt(connection)}
124
- header={CODE_BLOCK_HEADER}
146
+ header={snippetHeader(option, 'prompt')}
125
147
  wrapContents
126
148
  />
127
149
  </>
@@ -139,7 +161,7 @@ function renderTabContent(
139
161
  <CodeBlock
140
162
  lang="shell"
141
163
  source={generateClaudeCodeCommand(connection)}
142
- header={CODE_BLOCK_HEADER}
164
+ header={snippetHeader(option, 'claude-code')}
143
165
  wrapContents
144
166
  />
145
167
  </>
@@ -161,7 +183,7 @@ function renderTabContent(
161
183
  <CodeBlock
162
184
  lang="json"
163
185
  source={generateClaudeDesktopConfig(connection)}
164
- header={CODE_BLOCK_HEADER}
186
+ header={snippetHeader(option, 'claude-desktop')}
165
187
  />
166
188
  </>
167
189
  ),
@@ -177,7 +199,7 @@ function renderTabContent(
177
199
  <CodeBlock
178
200
  lang="shell"
179
201
  source={generateCodexCommand(connection)}
180
- header={CODE_BLOCK_HEADER}
202
+ header={snippetHeader(option, 'codex')}
181
203
  wrapContents
182
204
  />
183
205
  </>
@@ -218,7 +240,7 @@ function renderTabContent(
218
240
  null,
219
241
  2,
220
242
  )}
221
- header={CODE_BLOCK_HEADER}
243
+ header={snippetHeader(option, 'json')}
222
244
  />
223
245
  </>
224
246
  );
@@ -0,0 +1,3 @@
1
+ import { createContext } from 'react';
2
+
3
+ export const AccordionGroupContext = createContext<string | null>(null);
@@ -3,6 +3,7 @@ export * from './MarkdownLinkContext';
3
3
  export * from './PageActionsContext';
4
4
  export * from './CodeWalkthrough/CodeWalkthroughControlsContext';
5
5
  export * from './CodeWalkthrough/CodeWalkthroughStepsContext';
6
+ export * from './AccordionGroupContext';
6
7
  export * from './CodeSnippetContext';
7
8
  export * from './SearchContext';
8
9
  export * from './Toast/ToastContext';
@@ -18,6 +18,8 @@ export const useThemeHooks = vi.fn(() => ({
18
18
  useTelemetry: vi.fn(() => ({
19
19
  send: vi.fn(),
20
20
  sendCodeSnippetReportedMessage: vi.fn(),
21
+ sendConnectMcpClickedMessage: vi.fn(),
22
+ sendMcpConnectionSnippetCopiedMessage: vi.fn(),
21
23
  sendPageActionsButtonClickedMessage: vi.fn(),
22
24
  sendColorModeSwitchedMessage: vi.fn(),
23
25
  })),
@@ -95,6 +95,7 @@ export function useCodeWalkthroughSteps({
95
95
  });
96
96
  }
97
97
 
98
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates -- marker geometry is derived from post-layout measurements (offsetTop, clientHeight, window.innerHeight)
98
99
  setMarkers(
99
100
  markers.reduce(
100
101
  (acc, marker, index) => {
@@ -286,18 +286,22 @@ export function useActiveHeading(
286
286
  return;
287
287
  }
288
288
  const headers = findHeaders(contentElement);
289
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates -- headings and the initial active heading come from DOM queries and scroll measurement, available only after layout
289
290
  setHeadingElements(headers);
290
291
 
291
292
  if (headers.length > 0 && !heading) {
292
293
  if (isTopOfPage()) {
294
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates -- see above
293
295
  setHeading(headers[0].id);
294
296
  } else {
295
297
  const totalHeight = scrollY + window.innerHeight;
296
298
  if (totalHeight >= document.body.scrollHeight - 10) {
299
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates -- see above
297
300
  setHeading(headers[headers.length - 1]?.id || undefined);
298
301
  } else {
299
302
  const triggerOffset = getTriggerOffset();
300
303
  const activeHeader = findActiveHeaderByVirtualPosition(headers, scrollY, triggerOffset);
304
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates -- see above
301
305
  setHeading(activeHeader.id);
302
306
  }
303
307
  }
@@ -8,6 +8,7 @@ import { useMCPConfig } from './use-mcp-config';
8
8
  import { ClipboardService } from '../utils/clipboard-service';
9
9
  import { DEFAULT_MCP_OPTIONS } from '../constants/mcp';
10
10
  import { generateClaudeCodeCommand, generateCodexCommand } from '../utils/mcp';
11
+ import { getBaseDataAttributes } from '../utils/telemetry/get-base-data-attributes';
11
12
 
12
13
  export type McpButtonOptions = {
13
14
  options?: MCPOption[];
@@ -27,8 +28,9 @@ const COPIED_RESET_TIMEOUT = 1000;
27
28
  export function useConnectMCPButton({
28
29
  options = DEFAULT_MCP_OPTIONS,
29
30
  }: McpButtonOptions = {}): McpButtonSettings {
30
- const { useTranslate } = useThemeHooks();
31
+ const { useTranslate, useTelemetry } = useThemeHooks();
31
32
  const { translate } = useTranslate();
33
+ const telemetry = useTelemetry();
32
34
  const { serverName, serverUrl, cursorUrl, vscodeUrl } = useMCPConfig();
33
35
  const [copiedOption, setCopiedOption] = useState<MCPOption | null>(null);
34
36
 
@@ -40,6 +42,10 @@ export function useConnectMCPButton({
40
42
 
41
43
  const handleAction = useCallback(
42
44
  (action: MCPOption) => {
45
+ telemetry.sendConnectMcpClickedMessage([
46
+ { ...getBaseDataAttributes('connectMcp', 'button'), option: action },
47
+ ]);
48
+
43
49
  const connection = { serverName, url: serverUrl };
44
50
  const copyTextMap: Partial<Record<MCPOption, string>> = {
45
51
  copy: JSON.stringify(
@@ -66,7 +72,7 @@ export function useConnectMCPButton({
66
72
 
67
73
  window.open(urlMap[action], '_blank');
68
74
  },
69
- [cursorUrl, vscodeUrl, serverUrl, serverName, resetCopied],
75
+ [cursorUrl, vscodeUrl, serverUrl, serverName, resetCopied, telemetry],
70
76
  );
71
77
 
72
78
  const triggerButtonText = useMemo(
@@ -289,14 +289,16 @@ export const useActiveTab = ({ initialTab, tabsId }: UseActiveTabProps) => {
289
289
  const [activeTab, setActiveTab] = useState<string | undefined>(undefined);
290
290
  const resolvedActiveTab = activeTab ?? initialTab;
291
291
  const prevActiveTabRef = useRef(resolvedActiveTab);
292
+ const isTabResolvedFromHashRef = useRef(false);
292
293
 
293
294
  useEffect(() => {
294
- if (activeTab !== undefined) {
295
+ if (isTabResolvedFromHashRef.current) {
295
296
  return;
296
297
  }
298
+ isTabResolvedFromHashRef.current = true;
297
299
 
298
300
  setActiveTab(getInitialTab({ initialTab, hash: location.hash, tabsId }));
299
- }, [activeTab, initialTab, location.hash, tabsId]);
301
+ }, [initialTab, location.hash, tabsId]);
300
302
 
301
303
  useEffect(() => {
302
304
  const hasActiveTabChanged = prevActiveTabRef.current !== resolvedActiveTab;
@@ -36,6 +36,8 @@ export const useTelemetryFallback = () => ({
36
36
  sendProductPickerChangedMessage: () => {},
37
37
  sendLogoClickedMessage: () => {},
38
38
  sendCodeSnippetReportedMessage: () => {},
39
+ sendConnectMcpClickedMessage: () => {},
40
+ sendMcpConnectionSnippetCopiedMessage: () => {},
39
41
  sendMobileMenuClosedMessage: () => {},
40
42
  sendMobileMenuOpenedMessage: () => {},
41
43
  sendPageViewedMessage: () => {},
@@ -1,10 +1,11 @@
1
- import React from 'react';
1
+ import React, { useContext } from 'react';
2
2
  import styled, { keyframes } from 'styled-components';
3
3
 
4
4
  import type { JSX, PropsWithChildren } from 'react';
5
5
 
6
6
  import { ChevronRightIcon } from '@redocly/theme/icons/ChevronRightIcon/ChevronRightIcon';
7
7
  import { ACCORDION_COMPONENT_NAME } from '@redocly/theme/core/constants';
8
+ import { AccordionGroupContext } from '@redocly/theme/core/contexts';
8
9
 
9
10
  export type AccordionProps = {
10
11
  title: string;
@@ -22,10 +23,13 @@ export function Accordion({
22
23
  'data-source': dataSource,
23
24
  'data-hash': dataHash,
24
25
  }: PropsWithChildren<AccordionProps>): JSX.Element {
26
+ const groupName = useContext(AccordionGroupContext);
27
+
25
28
  return (
26
29
  <AccordionWrapper
27
30
  className={className}
28
31
  open={expanded}
32
+ name={groupName ?? undefined}
29
33
  data-component-name={ACCORDION_COMPONENT_NAME}
30
34
  data-source={dataSource}
31
35
  data-hash={dataHash}
@@ -37,7 +41,9 @@ export function Accordion({
37
41
  />
38
42
  {title}
39
43
  </AccordionSummary>
40
- <AccordionBody>{children}</AccordionBody>
44
+ <AccordionBody>
45
+ <AccordionGroupContext.Provider value={null}>{children}</AccordionGroupContext.Provider>
46
+ </AccordionBody>
41
47
  </AccordionWrapper>
42
48
  );
43
49
  }
@@ -1,24 +1,31 @@
1
- import React from 'react';
1
+ import React, { useId } from 'react';
2
2
  import styled from 'styled-components';
3
3
 
4
4
  import type { JSX, PropsWithChildren } from 'react';
5
5
 
6
6
  import { ACCORDION_GROUP_COMPONENT_NAME, ACCORDION_SELECTOR } from '@redocly/theme/core/constants';
7
+ import { AccordionGroupContext } from '@redocly/theme/core/contexts';
7
8
 
8
9
  export type AccordionGroupProps = {
10
+ singleExpanded?: boolean;
9
11
  className?: string;
10
12
  };
11
13
 
12
14
  export function AccordionGroup({
15
+ singleExpanded = false,
13
16
  className,
14
17
  children,
15
18
  }: PropsWithChildren<AccordionGroupProps>): JSX.Element {
19
+ const groupName = useId();
20
+
16
21
  return (
17
22
  <AccordionGroupWrapper
18
23
  className={className}
19
24
  data-component-name={ACCORDION_GROUP_COMPONENT_NAME}
20
25
  >
21
- {children}
26
+ <AccordionGroupContext.Provider value={singleExpanded ? groupName : null}>
27
+ {children}
28
+ </AccordionGroupContext.Provider>
22
29
  </AccordionGroupWrapper>
23
30
  );
24
31
  }
@@ -3,6 +3,9 @@ import type { MarkdocSchemaWrapper } from '@redocly/theme/markdoc/tags/types';
3
3
  export const accordionGroup: MarkdocSchemaWrapper = {
4
4
  schema: {
5
5
  render: 'AccordionGroup',
6
+ attributes: {
7
+ singleExpanded: { type: Boolean, default: false },
8
+ },
6
9
  },
7
10
  tagName: 'accordion-group',
8
11
  };