@redocly/theme 0.69.0-next.9 → 0.69.0

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 (50) hide show
  1. package/lib/components/Catalog/CatalogEntities.js +1 -0
  2. package/lib/components/MCP/McpConnectionTabs.js +21 -11
  3. package/lib/components/Markdown/Markdown.js +1 -3
  4. package/lib/components/Markdown/styles/base-table.js +8 -0
  5. package/lib/components/Markdown/variables.js +1 -2
  6. package/lib/components/Search/SearchAiDialog.js +3 -0
  7. package/lib/core/contexts/AccordionGroupContext.d.ts +1 -0
  8. package/lib/core/contexts/AccordionGroupContext.js +6 -0
  9. package/lib/core/contexts/PageActionsContext.d.ts +5 -0
  10. package/lib/core/contexts/PageActionsContext.js +10 -0
  11. package/lib/core/contexts/index.d.ts +2 -0
  12. package/lib/core/contexts/index.js +2 -0
  13. package/lib/core/hooks/code-walkthrough/use-code-walkthrough-steps.js +1 -0
  14. package/lib/core/hooks/use-active-heading.js +4 -0
  15. package/lib/core/hooks/use-connect-mcp-button.js +7 -2
  16. package/lib/core/hooks/use-product-picker.js +1 -1
  17. package/lib/core/hooks/use-tabs.js +4 -2
  18. package/lib/core/hooks/use-telemetry-fallback.d.ts +2 -0
  19. package/lib/core/hooks/use-telemetry-fallback.js +2 -0
  20. package/lib/core/styles/global.js +1 -0
  21. package/lib/core/utils/class-names.d.ts +1 -0
  22. package/lib/core/utils/class-names.js +7 -0
  23. package/lib/markdoc/components/Accordion/Accordion.js +6 -6
  24. package/lib/markdoc/components/Accordion/AccordionGroup.d.ts +2 -1
  25. package/lib/markdoc/components/Accordion/AccordionGroup.js +39 -3
  26. package/lib/markdoc/components/Heading/Heading.js +5 -2
  27. package/lib/markdoc/tags/accordion-group.js +3 -0
  28. package/package.json +2 -2
  29. package/src/components/Catalog/CatalogEntities.tsx +1 -0
  30. package/src/components/MCP/McpConnectionTabs.tsx +32 -10
  31. package/src/components/Markdown/Markdown.tsx +1 -3
  32. package/src/components/Markdown/styles/base-table.ts +8 -0
  33. package/src/components/Markdown/variables.ts +1 -2
  34. package/src/components/Search/SearchAiDialog.tsx +3 -0
  35. package/src/core/contexts/AccordionGroupContext.tsx +3 -0
  36. package/src/core/contexts/PageActionsContext.tsx +7 -0
  37. package/src/core/contexts/index.ts +2 -0
  38. package/src/core/hooks/__mocks__/use-theme-hooks.ts +2 -0
  39. package/src/core/hooks/code-walkthrough/use-code-walkthrough-steps.ts +1 -0
  40. package/src/core/hooks/use-active-heading.ts +4 -0
  41. package/src/core/hooks/use-connect-mcp-button.ts +8 -2
  42. package/src/core/hooks/use-product-picker.ts +2 -2
  43. package/src/core/hooks/use-tabs.ts +4 -2
  44. package/src/core/hooks/use-telemetry-fallback.ts +2 -0
  45. package/src/core/styles/global.ts +1 -0
  46. package/src/core/utils/class-names.ts +8 -0
  47. package/src/markdoc/components/Accordion/Accordion.tsx +8 -2
  48. package/src/markdoc/components/Accordion/AccordionGroup.tsx +9 -2
  49. package/src/markdoc/components/Heading/Heading.tsx +7 -4
  50. package/src/markdoc/tags/accordion-group.ts +3 -0
@@ -70,6 +70,7 @@ function CatalogEntities(props) {
70
70
  const shouldShowLoadMore = query.hasNextPage ||
71
71
  (query.isPlaceholderData && entities && entities.length >= LOAD_MORE_THRESHOLD);
72
72
  (0, react_1.useEffect)(() => {
73
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- the entities query lives in this ejectable component; the fetched total is mirrored up so the portal page header can show the count
73
74
  setEntitiesCounter(total || 0);
74
75
  }, [total, setEntitiesCounter]);
75
76
  if (!query.isPending && (!entities || entities.length === 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) {
@@ -178,10 +178,8 @@ exports.Markdown = styled_components_1.default.main
178
178
  }
179
179
 
180
180
  hr {
181
- border-top: 1px solid var(--md-hr-border-color);
182
- background-color: var(--md-hr-bg-color);
183
- height: var(--md-hr-height);
184
181
  margin: var(--md-hr-margin-vertical) 0;
182
+ border: var(--md-hr-border);
185
183
  }
186
184
 
187
185
  details:not(${constants_1.ACCORDION_SELECTOR}) {
@@ -46,6 +46,14 @@ exports.markdownBaseTableCss = (0, styled_components_1.css) `
46
46
  background-color: var(--md-table-header-bg-color);
47
47
  }
48
48
 
49
+ /* hide the header row when every cell is empty; one labelled cell keeps it visible */
50
+ thead:has(th:empty) {
51
+ display: none;
52
+ }
53
+ thead:has(th:not(:empty)) {
54
+ display: table-header-group;
55
+ }
56
+
49
57
  tr {
50
58
  background-color: var(--md-table-stripe-bg-color);
51
59
  }
@@ -62,9 +62,8 @@ exports.markdown = (0, styled_components_1.css) `
62
62
  * @tokens Markdown Horizontal Rule
63
63
  */
64
64
 
65
- --md-hr-bg-color: transparent; // @presenter Color
66
65
  --md-hr-border-color: var(--border-color-secondary); // @presenter Color
67
- --md-hr-height: unset;
66
+ --md-hr-border: 0.5px solid var(--md-hr-border-color); // @presenter Border
68
67
  --md-hr-margin-vertical: 20px; // @presenter Spacing
69
68
 
70
69
  /**
@@ -80,6 +80,7 @@ function SearchAiDialog({ isGeneratingResponse, response, initialMessage, error,
80
80
  if (!(initialMessage === null || initialMessage === void 0 ? void 0 : initialMessage.trim().length)) {
81
81
  return;
82
82
  }
83
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- appends the initial message into the conversation state lifted to the search root once the dialog mounts
83
84
  setConversation((prev) => [
84
85
  ...prev,
85
86
  { role: constants_1.AiSearchConversationRole.USER, content: initialMessage },
@@ -89,6 +90,7 @@ function SearchAiDialog({ isGeneratingResponse, response, initialMessage, error,
89
90
  if (response === undefined || conversation.length === 0 || error) {
90
91
  return;
91
92
  }
93
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- merges the streamed assistant response into the lifted conversation state as chunks arrive
92
94
  setConversation((prev) => {
93
95
  const lastMessage = prev[prev.length - 1];
94
96
  const content = response || '';
@@ -127,6 +129,7 @@ function SearchAiDialog({ isGeneratingResponse, response, initialMessage, error,
127
129
  ]);
128
130
  (0, react_1.useEffect)(() => {
129
131
  if (error) {
132
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- drops the pending message from the lifted conversation state once the request errors
130
133
  setConversation((prev) => prev.slice(0, -1));
131
134
  }
132
135
  }, [error, setConversation]);
@@ -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
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Whether the first heading inside the enclosing `Markdown` mounts the page actions.
3
+ * Without a provider `Heading` falls back to the page type in the page metadata.
4
+ */
5
+ export declare const PageActionsContext: import("react").Context<boolean | null>;
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PageActionsContext = void 0;
4
+ const react_1 = require("react");
5
+ /**
6
+ * Whether the first heading inside the enclosing `Markdown` mounts the page actions.
7
+ * Without a provider `Heading` falls back to the page type in the page metadata.
8
+ */
9
+ exports.PageActionsContext = (0, react_1.createContext)(null);
10
+ //# sourceMappingURL=PageActionsContext.js.map
@@ -1,7 +1,9 @@
1
1
  export * from './ThemeDataContext';
2
2
  export * from './MarkdownLinkContext';
3
+ export * from './PageActionsContext';
3
4
  export * from './CodeWalkthrough/CodeWalkthroughControlsContext';
4
5
  export * from './CodeWalkthrough/CodeWalkthroughStepsContext';
6
+ export * from './AccordionGroupContext';
5
7
  export * from './CodeSnippetContext';
6
8
  export * from './SearchContext';
7
9
  export * from './Toast/ToastContext';
@@ -16,8 +16,10 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./ThemeDataContext"), exports);
18
18
  __exportStar(require("./MarkdownLinkContext"), exports);
19
+ __exportStar(require("./PageActionsContext"), exports);
19
20
  __exportStar(require("./CodeWalkthrough/CodeWalkthroughControlsContext"), exports);
20
21
  __exportStar(require("./CodeWalkthrough/CodeWalkthroughStepsContext"), exports);
22
+ __exportStar(require("./AccordionGroupContext"), exports);
21
23
  __exportStar(require("./CodeSnippetContext"), exports);
22
24
  __exportStar(require("./SearchContext"), exports);
23
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 {
@@ -33,7 +33,7 @@ function useProductPicker() {
33
33
  Array.from(root.classList)
34
34
  .filter((c) => c.startsWith('product-'))
35
35
  .forEach((c) => root.classList.remove(c));
36
- root.classList.add((0, utils_1.getProductClassName)(freshProduct.name));
36
+ root.classList.add((0, utils_2.getSafeClassName)((0, utils_1.getProductClassName)(freshProduct.name)));
37
37
  }
38
38
  loadAndNavigate({ navigate, to: (0, utils_2.withPathPrefix)(freshProduct.link) });
39
39
  }
@@ -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: () => { },
@@ -592,6 +592,7 @@ const links = (0, styled_components_1.css) `
592
592
 
593
593
  // @tokens End
594
594
  `;
595
+ // oxlint-disable-next-line theme/css-variable-pattern -- `--schema-labels--text-color` is a documented public variable
595
596
  const apiReferenceDocs = (0, styled_components_1.css) `
596
597
  /* === ref docs and graphql docs specific === */
597
598
 
@@ -1 +1,2 @@
1
+ export declare function getSafeClassName(className: string): string;
1
2
  export declare function concatClassNames(...args: Array<string | undefined>): string;
@@ -1,7 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getSafeClassName = getSafeClassName;
3
4
  exports.concatClassNames = concatClassNames;
4
5
  const SPACE_DELIMITER = ' ';
6
+ // EasyList/AdGuard ship generic cosmetic rules that hide these exact class tokens on every
7
+ // site; the trailing underscore keeps the class usable without matching the filter lists.
8
+ const ADBLOCK_HIDDEN_CLASS_NAMES = new Set(['product-ad', 'product-ads', 'product-inlist-ad']);
9
+ function getSafeClassName(className) {
10
+ return ADBLOCK_HIDDEN_CLASS_NAMES.has(className) ? `${className}_` : className;
11
+ }
5
12
  function concatClassNames(...args) {
6
13
  return args
7
14
  .map((className) => (className ? className.trim() : className))
@@ -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);
@@ -45,6 +45,7 @@ const utils_1 = require("../../../core/utils");
45
45
  const LinkIcon_1 = require("../../../icons/LinkIcon/LinkIcon");
46
46
  const PageActions_1 = require("../../../components/PageActions/PageActions");
47
47
  const hooks_1 = require("../../../core/hooks");
48
+ const contexts_1 = require("../../../core/contexts");
48
49
  const constants_1 = require("../../../core/constants");
49
50
  function renderWithSpanWrapper(children) {
50
51
  const childrenArray = react_1.default.Children.toArray(children);
@@ -68,10 +69,12 @@ function renderWithSpanWrapper(children) {
68
69
  }
69
70
  function Heading({ level, id, children, 'data-source': dataSource, 'data-hash': dataHash, className, __idx, }) {
70
71
  var _a, _b;
72
+ var _c;
71
73
  const { pathname } = (0, react_router_1.useLocation)();
72
74
  const { usePageProps } = (0, hooks_1.useThemeHooks)();
73
75
  const pageProps = usePageProps();
74
- const isMarkdownPage = ((_a = pageProps === null || pageProps === void 0 ? void 0 : pageProps.metadata) === null || _a === void 0 ? void 0 : _a.type) === 'markdown' || ((_b = pageProps === null || pageProps === void 0 ? void 0 : pageProps.metadata) === null || _b === void 0 ? void 0 : _b.type) === 'asciidoc';
76
+ // The page-type fallback is only for backward compatibility; templates set the scope.
77
+ const withPageActions = (_c = (0, react_1.useContext)(contexts_1.PageActionsContext)) !== null && _c !== void 0 ? _c : (((_a = pageProps === null || pageProps === void 0 ? void 0 : pageProps.metadata) === null || _a === void 0 ? void 0 : _a.type) === 'markdown' || ((_b = pageProps === null || pageProps === void 0 ? void 0 : pageProps.metadata) === null || _b === void 0 ? void 0 : _b.type) === 'asciidoc');
75
78
  const headingTextId = `${id}-heading-text`;
76
79
  const linkEl = (react_1.default.createElement("a", { "aria-labelledby": headingTextId, href: `#${id}`, className: (0, utils_1.concatClassNames)(constants_1.ANCHOR_CLASS, 'before') },
77
80
  react_1.default.createElement(LinkIcon_1.LinkIcon, { "aria-hidden": "true", focusable: "false", color: "--heading-anchor-color" })));
@@ -80,7 +83,7 @@ function Heading({ level, id, children, 'data-source': dataSource, 'data-hash':
80
83
  react_1.default.createElement(HeadingContentWrapper, null,
81
84
  linkEl,
82
85
  headingText,
83
- isMarkdownPage && __idx === 0 ? react_1.default.createElement(PageActions_1.PageActions, { pageSlug: pathname }) : null)));
86
+ withPageActions && __idx === 0 ? react_1.default.createElement(PageActions_1.PageActions, { pageSlug: pathname }) : null)));
84
87
  }
85
88
  const HeadingContentWrapper = styled_components_1.default.div `
86
89
  display: flex;
@@ -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.9",
3
+ "version": "0.69.0",
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",
46
46
  "@shikijs/types": "3.21.0",
47
47
  "@testing-library/react": "16.3.0",
48
48
  "@testing-library/user-event": "14.6.1",
@@ -88,6 +88,7 @@ export function CatalogEntities(props: CatalogEntitiesProps): JSX.Element {
88
88
  (query.isPlaceholderData && entities && entities.length >= LOAD_MORE_THRESHOLD);
89
89
 
90
90
  useEffect(() => {
91
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- the entities query lives in this ejectable component; the fetched total is mirrored up so the portal page header can show the count
91
92
  setEntitiesCounter(total || 0);
92
93
  }, [total, setEntitiesCounter]);
93
94
 
@@ -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
  );
@@ -167,10 +167,8 @@ export const Markdown = styled.main
167
167
  }
168
168
 
169
169
  hr {
170
- border-top: 1px solid var(--md-hr-border-color);
171
- background-color: var(--md-hr-bg-color);
172
- height: var(--md-hr-height);
173
170
  margin: var(--md-hr-margin-vertical) 0;
171
+ border: var(--md-hr-border);
174
172
  }
175
173
 
176
174
  details:not(${ACCORDION_SELECTOR}) {
@@ -45,6 +45,14 @@ export const markdownBaseTableCss = css`
45
45
  background-color: var(--md-table-header-bg-color);
46
46
  }
47
47
 
48
+ /* hide the header row when every cell is empty; one labelled cell keeps it visible */
49
+ thead:has(th:empty) {
50
+ display: none;
51
+ }
52
+ thead:has(th:not(:empty)) {
53
+ display: table-header-group;
54
+ }
55
+
48
56
  tr {
49
57
  background-color: var(--md-table-stripe-bg-color);
50
58
  }
@@ -60,9 +60,8 @@ export const markdown = css`
60
60
  * @tokens Markdown Horizontal Rule
61
61
  */
62
62
 
63
- --md-hr-bg-color: transparent; // @presenter Color
64
63
  --md-hr-border-color: var(--border-color-secondary); // @presenter Color
65
- --md-hr-height: unset;
64
+ --md-hr-border: 0.5px solid var(--md-hr-border-color); // @presenter Border
66
65
  --md-hr-margin-vertical: 20px; // @presenter Spacing
67
66
 
68
67
  /**
@@ -94,6 +94,7 @@ export function SearchAiDialog({
94
94
  if (!initialMessage?.trim().length) {
95
95
  return;
96
96
  }
97
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- appends the initial message into the conversation state lifted to the search root once the dialog mounts
97
98
  setConversation((prev) => [
98
99
  ...prev,
99
100
  { role: AiSearchConversationRole.USER, content: initialMessage },
@@ -105,6 +106,7 @@ export function SearchAiDialog({
105
106
  return;
106
107
  }
107
108
 
109
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- merges the streamed assistant response into the lifted conversation state as chunks arrive
108
110
  setConversation((prev) => {
109
111
  const lastMessage = prev[prev.length - 1];
110
112
  const content: string = response || '';
@@ -146,6 +148,7 @@ export function SearchAiDialog({
146
148
 
147
149
  useEffect(() => {
148
150
  if (error) {
151
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- drops the pending message from the lifted conversation state once the request errors
149
152
  setConversation((prev) => prev.slice(0, -1));
150
153
  }
151
154
  }, [error, setConversation]);
@@ -0,0 +1,3 @@
1
+ import { createContext } from 'react';
2
+
3
+ export const AccordionGroupContext = createContext<string | null>(null);
@@ -0,0 +1,7 @@
1
+ import { createContext } from 'react';
2
+
3
+ /**
4
+ * Whether the first heading inside the enclosing `Markdown` mounts the page actions.
5
+ * Without a provider `Heading` falls back to the page type in the page metadata.
6
+ */
7
+ export const PageActionsContext = createContext<boolean | null>(null);
@@ -1,7 +1,9 @@
1
1
  export * from './ThemeDataContext';
2
2
  export * from './MarkdownLinkContext';
3
+ export * from './PageActionsContext';
3
4
  export * from './CodeWalkthrough/CodeWalkthroughControlsContext';
4
5
  export * from './CodeWalkthrough/CodeWalkthroughStepsContext';
6
+ export * from './AccordionGroupContext';
5
7
  export * from './CodeSnippetContext';
6
8
  export * from './SearchContext';
7
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(
@@ -4,7 +4,7 @@ import { useNavigate } from 'react-router';
4
4
  import { getProductClassName } from '@redocly/theme/components/Product/utils';
5
5
 
6
6
  import { useThemeHooks } from './use-theme-hooks';
7
- import { withPathPrefix, generateBeforeAfterContext } from '../utils';
7
+ import { withPathPrefix, generateBeforeAfterContext, getSafeClassName } from '../utils';
8
8
 
9
9
  export function useProductPicker() {
10
10
  const { useCurrentProduct, useProducts, useTelemetry, useLoadAndNavigate } = useThemeHooks();
@@ -36,7 +36,7 @@ export function useProductPicker() {
36
36
  Array.from(root.classList)
37
37
  .filter((c) => c.startsWith('product-'))
38
38
  .forEach((c) => root.classList.remove(c));
39
- root.classList.add(getProductClassName(freshProduct.name));
39
+ root.classList.add(getSafeClassName(getProductClassName(freshProduct.name)));
40
40
  }
41
41
 
42
42
  loadAndNavigate({ navigate, to: withPathPrefix(freshProduct.link) });
@@ -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: () => {},
@@ -599,6 +599,7 @@ const links = css`
599
599
  `;
600
600
 
601
601
 
602
+ // oxlint-disable-next-line theme/css-variable-pattern -- `--schema-labels--text-color` is a documented public variable
602
603
  const apiReferenceDocs = css`
603
604
  /* === ref docs and graphql docs specific === */
604
605
 
@@ -1,5 +1,13 @@
1
1
  const SPACE_DELIMITER = ' ';
2
2
 
3
+ // EasyList/AdGuard ship generic cosmetic rules that hide these exact class tokens on every
4
+ // site; the trailing underscore keeps the class usable without matching the filter lists.
5
+ const ADBLOCK_HIDDEN_CLASS_NAMES = new Set(['product-ad', 'product-ads', 'product-inlist-ad']);
6
+
7
+ export function getSafeClassName(className: string): string {
8
+ return ADBLOCK_HIDDEN_CLASS_NAMES.has(className) ? `${className}_` : className;
9
+ }
10
+
3
11
  export function concatClassNames(...args: Array<string | undefined>): string {
4
12
  return args
5
13
  .map((className) => (className ? className.trim() : className))
@@ -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
  }
@@ -1,4 +1,4 @@
1
- import React, { createElement, isValidElement } from 'react';
1
+ import React, { createElement, isValidElement, useContext } from 'react';
2
2
  import styled from 'styled-components';
3
3
  import { useLocation } from 'react-router';
4
4
 
@@ -9,6 +9,7 @@ import { concatClassNames } from '@redocly/theme/core/utils';
9
9
  import { LinkIcon } from '@redocly/theme/icons/LinkIcon/LinkIcon';
10
10
  import { PageActions } from '@redocly/theme/components/PageActions/PageActions';
11
11
  import { useThemeHooks } from '@redocly/theme/core/hooks';
12
+ import { PageActionsContext } from '@redocly/theme/core/contexts';
12
13
  import {
13
14
  ANCHOR_CLASS,
14
15
  HEADING_ANCHOR_CLASS,
@@ -65,8 +66,10 @@ export function Heading({
65
66
  const { usePageProps } = useThemeHooks();
66
67
  const pageProps = usePageProps();
67
68
 
68
- const isMarkdownPage =
69
- pageProps?.metadata?.type === 'markdown' || pageProps?.metadata?.type === 'asciidoc';
69
+ // The page-type fallback is only for backward compatibility; templates set the scope.
70
+ const withPageActions =
71
+ useContext(PageActionsContext) ??
72
+ (pageProps?.metadata?.type === 'markdown' || pageProps?.metadata?.type === 'asciidoc');
70
73
  const headingTextId = `${id}-heading-text`;
71
74
 
72
75
  const linkEl = (
@@ -101,7 +104,7 @@ export function Heading({
101
104
  <HeadingContentWrapper>
102
105
  {linkEl}
103
106
  {headingText}
104
- {isMarkdownPage && __idx === 0 ? <PageActions pageSlug={pathname} /> : null}
107
+ {withPageActions && __idx === 0 ? <PageActions pageSlug={pathname} /> : null}
105
108
  </HeadingContentWrapper>
106
109
  </div>
107
110
  );
@@ -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
  };