@redocly/theme 0.58.0-next.9 → 0.58.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 (54) hide show
  1. package/lib/components/Catalog/CatalogEntity/CatalogEntity.d.ts +5 -1
  2. package/lib/components/Catalog/CatalogEntity/CatalogEntity.js +4 -4
  3. package/lib/components/Catalog/CatalogEntity/CatalogEntityMetadata.js +3 -3
  4. package/lib/components/Catalog/CatalogEntity/CatalogEntitySchema.d.ts +5 -1
  5. package/lib/components/Catalog/CatalogEntity/CatalogEntitySchema.js +9 -7
  6. package/lib/components/CodeBlock/CodeBlock.d.ts +5 -12
  7. package/lib/components/CodeBlock/CodeBlockControls.d.ts +3 -3
  8. package/lib/components/CodeBlock/CodeBlockControls.js +1 -1
  9. package/lib/components/CodeBlock/CodeBlockDropdown.d.ts +2 -2
  10. package/lib/components/CodeBlock/CodeBlockDropdown.js +4 -13
  11. package/lib/components/CodeBlock/CodeBlockTabs.d.ts +2 -2
  12. package/lib/components/CodeBlock/CodeBlockTabs.js +4 -3
  13. package/lib/components/JsonViewer/JsonViewer.d.ts +1 -1
  14. package/lib/components/JsonViewer/JsonViewer.js +9 -10
  15. package/lib/components/PageActions/PageActions.d.ts +4 -1
  16. package/lib/components/PageActions/PageActions.js +2 -2
  17. package/lib/core/constants/catalog.js +4 -0
  18. package/lib/core/contexts/CodeSnippetContext.d.ts +14 -6
  19. package/lib/core/contexts/CodeSnippetContext.js +57 -14
  20. package/lib/core/hooks/use-codeblock-tabs-controls.d.ts +2 -2
  21. package/lib/core/hooks/use-local-state.js +22 -18
  22. package/lib/core/hooks/use-page-actions.d.ts +2 -1
  23. package/lib/core/hooks/use-page-actions.js +48 -6
  24. package/lib/core/openapi/index.d.ts +1 -0
  25. package/lib/core/openapi/index.js +3 -1
  26. package/lib/core/types/l10n.d.ts +1 -1
  27. package/lib/core/types/open-api-server.d.ts +1 -0
  28. package/lib/icons/CursorIcon/CursorIcon.d.ts +9 -0
  29. package/lib/icons/CursorIcon/CursorIcon.js +22 -0
  30. package/lib/layouts/DocumentationLayout.js +1 -3
  31. package/lib/markdoc/components/CodeGroup/CodeGroup.js +49 -27
  32. package/lib/markdoc/components/Tabs/TabList.js +2 -0
  33. package/package.json +4 -4
  34. package/src/components/Catalog/CatalogEntity/CatalogEntity.tsx +15 -2
  35. package/src/components/Catalog/CatalogEntity/CatalogEntityMetadata.tsx +3 -3
  36. package/src/components/Catalog/CatalogEntity/CatalogEntitySchema.tsx +27 -18
  37. package/src/components/CodeBlock/CodeBlock.tsx +5 -11
  38. package/src/components/CodeBlock/CodeBlockControls.tsx +4 -7
  39. package/src/components/CodeBlock/CodeBlockDropdown.tsx +11 -20
  40. package/src/components/CodeBlock/CodeBlockTabs.tsx +8 -8
  41. package/src/components/JsonViewer/JsonViewer.tsx +16 -9
  42. package/src/components/PageActions/PageActions.tsx +6 -4
  43. package/src/core/constants/catalog.ts +4 -0
  44. package/src/core/contexts/CodeSnippetContext.tsx +54 -18
  45. package/src/core/hooks/use-codeblock-tabs-controls.ts +2 -2
  46. package/src/core/hooks/use-local-state.ts +28 -19
  47. package/src/core/hooks/use-page-actions.ts +63 -6
  48. package/src/core/openapi/index.ts +1 -0
  49. package/src/core/types/l10n.ts +13 -0
  50. package/src/core/types/open-api-server.ts +1 -0
  51. package/src/icons/CursorIcon/CursorIcon.tsx +35 -0
  52. package/src/layouts/DocumentationLayout.tsx +3 -10
  53. package/src/markdoc/components/CodeGroup/CodeGroup.tsx +81 -52
  54. package/src/markdoc/components/Tabs/TabList.tsx +1 -0
@@ -12,11 +12,11 @@ export type PanelType = 'request' | 'responses' | 'request-samples' | 'response-
12
12
 
13
13
  export type JsonProps = {
14
14
  title?: CodeBlockControlsProps['title'];
15
+ controls?: CodeBlockControlsProps['controls'];
15
16
  data: any;
16
17
  className?: string;
17
18
  expandLevel: number;
18
19
  startLineNumber?: number;
19
- hideHeader?: boolean;
20
20
  onCopyClick?: () => void;
21
21
  onPanelToggle?: (isExpanded: boolean, panelType?: PanelType) => void;
22
22
  };
@@ -28,7 +28,7 @@ function JsonComponent({
28
28
  onCopyClick,
29
29
  onPanelToggle,
30
30
  title,
31
- hideHeader,
31
+ controls = {},
32
32
  }: JsonProps): JSX.Element {
33
33
  const showFoldingButtons =
34
34
  data && Object.values(data).some((value) => typeof value === 'object' && value !== null);
@@ -53,6 +53,8 @@ function JsonComponent({
53
53
 
54
54
  const source = JSON.stringify(data, null, 2);
55
55
 
56
+ const hasHeader = title || controls;
57
+
56
58
  return (
57
59
  <JsonViewerWrap
58
60
  data-testid="json-viewer"
@@ -61,17 +63,22 @@ function JsonComponent({
61
63
  >
62
64
  <CodeBlock
63
65
  header={
64
- hideHeader
65
- ? undefined
66
- : {
66
+ hasHeader
67
+ ? {
67
68
  title,
68
69
  className: 'code-block-header',
69
- controls: {
70
- copy: { data, onClick: onCopyClick, handleOutside: true },
71
- expand: showFoldingButtons ? { onClick: expandAll } : undefined,
72
- collapse: showFoldingButtons ? { onClick: collapseAll } : undefined,
70
+ controls: controls && {
71
+ ...controls,
72
+ copy: { ...controls.copy, data, onClick: onCopyClick, handleOutside: true },
73
+ expand: showFoldingButtons
74
+ ? { ...controls.expand, onClick: expandAll }
75
+ : undefined,
76
+ collapse: showFoldingButtons
77
+ ? { ...controls.collapse, onClick: collapseAll }
78
+ : undefined,
73
79
  },
74
80
  }
81
+ : undefined
75
82
  }
76
83
  source={source}
77
84
  >
@@ -13,20 +13,22 @@ import { Dropdown } from '@redocly/theme/components/Dropdown/Dropdown';
13
13
  import { DropdownMenu } from '@redocly/theme/components/Dropdown/DropdownMenu';
14
14
  import { Spinner } from '@redocly/theme/icons/Spinner/Spinner';
15
15
  import { CheckmarkFilledIcon } from '@redocly/theme/icons/CheckmarkFilledIcon/CheckmarkFilledIcon';
16
- import { usePageActions } from '@redocly/theme/core/hooks';
16
+ import { PageActionType, usePageActions } from '@redocly/theme/core/hooks';
17
17
 
18
18
  type ActionState = 'idle' | 'processing' | 'done';
19
19
 
20
20
  type PageActionProps = {
21
- pageSlug: string;
21
+ pageSlug?: string;
22
+ mcpUrl?: string;
23
+ actions?: PageActionType[];
22
24
  };
23
25
 
24
26
  const ACTION_DONE_DISPLAY_DURATION = 1000;
25
27
 
26
28
  export function PageActions(props: PageActionProps): JSX.Element | null {
27
- const { pageSlug } = props;
29
+ const { pageSlug, mcpUrl } = props;
28
30
 
29
- const actions = usePageActions(pageSlug || '/');
31
+ const actions = usePageActions(pageSlug || '/', mcpUrl, props.actions);
30
32
 
31
33
  const [actionState, setActionState] = useState<ActionState>('idle');
32
34
 
@@ -38,6 +38,8 @@ export const reverseRelationMap: Record<EntityRelationType, EntityRelationType>
38
38
  memberOf: 'hasMember',
39
39
  triggers: 'triggeredBy',
40
40
  triggeredBy: 'triggers',
41
+ returns: 'returnedBy',
42
+ returnedBy: 'returns',
41
43
  } as const;
42
44
 
43
45
  export const relationTypeMap: Record<EntityRelationType, string> = {
@@ -66,6 +68,8 @@ export const relationTypeMap: Record<EntityRelationType, string> = {
66
68
  memberOf: 'Member of',
67
69
  triggers: 'Triggers',
68
70
  triggeredBy: 'Triggered by',
71
+ returns: 'Returns',
72
+ returnedBy: 'Returned by',
69
73
  };
70
74
 
71
75
  export enum GraphHandleType {
@@ -1,31 +1,67 @@
1
- import { createContext, useContext, useState } from 'react';
1
+ import React, { createContext, useContext, useCallback, useMemo } from 'react';
2
2
 
3
- export type CodeSnippetContextType = {
4
- activeSnippetName: string;
5
- setActiveSnippetName: (name: string) => void;
6
- };
3
+ import { useLocalState } from '../hooks/use-local-state';
7
4
 
8
5
  export const CODE_GROUP_SNIPPET_NAME_KEY = 'redocly:codeGroupSnippetName';
9
6
 
7
+ type CodeSnippetContextType = {
8
+ activeSnippets: Record<string, string>;
9
+ setActiveSnippet: (groupId: string, snippetId: string) => void;
10
+ };
11
+
10
12
  export const CodeSnippetContext = createContext<CodeSnippetContextType | null>(null);
11
13
 
12
- export function useActiveCodeSnippetName(
13
- mode: 'tabs' | 'dropdown',
14
- ): [string, (name: string) => void] {
14
+ export function CodeSnippetProvider({ children }: { children: React.ReactNode }) {
15
+ const [activeSnippets, setActiveSnippets] = useLocalState<Record<string, string>>(
16
+ CODE_GROUP_SNIPPET_NAME_KEY,
17
+ {},
18
+ );
19
+
20
+ const setActiveSnippet = useCallback(
21
+ (groupId: string, snippetId: string) => {
22
+ setActiveSnippets({ ...activeSnippets, [groupId]: snippetId });
23
+ },
24
+ [activeSnippets, setActiveSnippets],
25
+ );
26
+
27
+ const contextValue = { activeSnippets, setActiveSnippet };
28
+
29
+ return <CodeSnippetContext.Provider value={contextValue}>{children}</CodeSnippetContext.Provider>;
30
+ }
31
+
32
+ export const useCodeSnippetContext = (): CodeSnippetContextType => {
15
33
  const context = useContext(CodeSnippetContext);
34
+
16
35
  if (!context) {
17
36
  throw new Error('useCodeSnippetContext must be used within a CodeSnippetContext');
18
37
  }
19
38
 
20
- const [activeSnippetName, setActiveSnippetName] = useState(
21
- mode === 'tabs' ? '' : context.activeSnippetName,
39
+ return context;
40
+ };
41
+
42
+ export const useActiveCodeSnippetId = (
43
+ groupId?: string,
44
+ availableSnippets?: { id: string }[],
45
+ ): [string, (id: string) => void] => {
46
+ const { activeSnippets, setActiveSnippet } = useCodeSnippetContext();
47
+
48
+ const storedSnippetId = groupId ? activeSnippets[groupId] || '' : '';
49
+
50
+ const activeId = useMemo(() => {
51
+ if (!availableSnippets?.length) return storedSnippetId;
52
+
53
+ const found = storedSnippetId && availableSnippets.find((s) => s.id === storedSnippetId);
54
+ return found ? storedSnippetId : availableSnippets[0]?.id || '';
55
+ }, [storedSnippetId, availableSnippets]);
56
+
57
+ const setActiveSnippetId = useCallback(
58
+ (id: string) => {
59
+ if (groupId) {
60
+ setActiveSnippet(groupId, id);
61
+ }
62
+ },
63
+ [groupId, setActiveSnippet],
22
64
  );
23
65
 
24
- if (mode === 'tabs') {
25
- // use non-synced state for tabs mode
26
- return [activeSnippetName, setActiveSnippetName];
27
- } else {
28
- // use global synced state for dropdown mode
29
- return [context.activeSnippetName, context.setActiveSnippetName];
30
- }
31
- }
66
+ return [activeId, setActiveSnippetId];
67
+ };
@@ -1,9 +1,9 @@
1
1
  import { useEffect, useState, useCallback, useMemo } from 'react';
2
2
 
3
- import type { CodeBlockTabItems } from '../../components/CodeBlock/CodeBlock';
3
+ import type { CodeBlockItems } from '../../components/CodeBlock/CodeBlock';
4
4
 
5
5
  type CodeBlockTabsProps = {
6
- tabs: CodeBlockTabItems;
6
+ tabs: CodeBlockItems;
7
7
  containerRef: React.RefObject<HTMLDivElement | null>;
8
8
  tabRefs: React.RefObject<HTMLButtonElement[]>;
9
9
  };
@@ -1,30 +1,39 @@
1
- import { useState, useEffect } from 'react';
1
+ import { useState, useEffect, useCallback } from 'react';
2
+
3
+ import { isBrowser } from '../utils/js-utils';
4
+
5
+ function getStoredValue<T>(key: string, fallback: T): T {
6
+ if (!isBrowser()) return fallback;
2
7
 
3
- function getInitialValue<T>(key: string, initialValue: T): T {
4
- if (typeof window === 'undefined') {
5
- return initialValue;
6
- }
7
8
  try {
8
9
  const savedValue = localStorage.getItem(key);
9
- if (savedValue) {
10
- return JSON.parse(savedValue) as T;
11
- }
12
- } catch (error) {
13
- console.error(`Error reading from localStorage for key "${key}":`, error);
10
+ return savedValue ? (JSON.parse(savedValue) as T) : fallback;
11
+ } catch {
12
+ return fallback;
14
13
  }
15
- return initialValue;
16
14
  }
17
15
 
18
16
  export function useLocalState<T>(key: string, initialValue: T): [T, (value: T) => void] {
19
- const [value, setValue] = useState<T>(() => getInitialValue(key, initialValue));
17
+ const [value, setValue] = useState<T>(initialValue);
20
18
 
19
+ // Load stored value from localStorage after component mounts
20
+ // This ensures SSR compatibility: server and client both start with initialValue,
21
+ // then client loads the actual stored value without hydration mismatch
21
22
  useEffect(() => {
22
- try {
23
- localStorage.setItem(key, JSON.stringify(value));
24
- } catch (error) {
25
- console.error(`Error writing to localStorage for key "${key}":`, error);
26
- }
27
- }, [key, value]);
23
+ if (!isBrowser()) return;
24
+ setValue(getStoredValue(key, initialValue));
25
+ // eslint-disable-next-line react-hooks/exhaustive-deps
26
+ }, [key]);
27
+
28
+ const handleSetValue = useCallback(
29
+ (newValue: T) => {
30
+ setValue(newValue);
31
+ if (isBrowser()) {
32
+ localStorage.setItem(key, JSON.stringify(newValue));
33
+ }
34
+ },
35
+ [key],
36
+ );
28
37
 
29
- return [value, setValue] as const;
38
+ return [value, handleSetValue] as const;
30
39
  }
@@ -12,10 +12,19 @@ import { useThemeHooks } from './use-theme-hooks';
12
12
  import { useThemeConfig } from './use-theme-config';
13
13
  import { ClipboardService } from '../utils/clipboard-service';
14
14
  import { IS_BROWSER } from '../utils/dom';
15
+ import { CursorIcon } from '../../icons/CursorIcon/CursorIcon';
15
16
 
16
17
  const DEFAULT_ENABLED_ACTIONS = ['copy', 'view', 'chatgpt', 'claude'] as const;
18
+ const CURSOR_URL =
19
+ 'cursor://anysphere.cursor-deeplink/mcp/install?name=$NAME&config=$BASE64_ENCODED_CONFIG';
17
20
 
18
- export function usePageActions(pageSlug: string): PageAction[] {
21
+ export type PageActionType = 'copy' | 'view' | 'chatgpt' | 'claude' | 'mcp-cursor';
22
+
23
+ export function usePageActions(
24
+ pageSlug: string,
25
+ mcpUrl?: string,
26
+ actions?: PageActionType[],
27
+ ): PageAction[] {
19
28
  const { useTranslate, usePageData, usePageProps, usePageSharedData } = useThemeHooks();
20
29
  const { translate } = useTranslate();
21
30
 
@@ -32,7 +41,7 @@ export function usePageActions(pageSlug: string): PageAction[] {
32
41
  );
33
42
  const { isPublic } = usePageData() || {};
34
43
 
35
- const actions: PageAction[] = useMemo(() => {
44
+ const result: PageAction[] = useMemo(() => {
36
45
  if (shouldHideAllActions) {
37
46
  return [];
38
47
  }
@@ -54,9 +63,49 @@ export function usePageActions(pageSlug: string): PageAction[] {
54
63
  return url.toString();
55
64
  }
56
65
 
57
- return (themeConfig.navigation?.actions?.items || DEFAULT_ENABLED_ACTIONS)
66
+ return (themeConfig.navigation?.actions?.items || actions || DEFAULT_ENABLED_ACTIONS)
58
67
  .map((action) => {
68
+ function generateMCPConfig(isCursor?: boolean): string {
69
+ const jsonConfig = {
70
+ 'mcp-server': {
71
+ url: mcpUrl,
72
+ description: 'MCP Server',
73
+ },
74
+ };
75
+ if (isCursor) {
76
+ const url = CURSOR_URL.replace('$NAME', 'mcp-server').replace(
77
+ '$BASE64_ENCODED_CONFIG',
78
+ btoa(JSON.stringify(jsonConfig['mcp-server'])),
79
+ );
80
+ return url;
81
+ }
82
+
83
+ return JSON.stringify(jsonConfig, null, 2);
84
+ }
85
+
59
86
  switch (action) {
87
+ case 'mcp-cursor':
88
+ if (!mcpUrl) {
89
+ return null;
90
+ }
91
+
92
+ return {
93
+ buttonText: translate('page.actions.cursorMcpButtonText', 'Connect to Cursor'),
94
+ title: translate('page.actions.cursorMcpTitle', 'Connect to Cursor'),
95
+ description: translate(
96
+ 'page.actions.cursorMcpDescription',
97
+ 'Install MCP server on Cursor',
98
+ ),
99
+ iconComponent: CursorIcon,
100
+ onClick: () => {
101
+ try {
102
+ const url = generateMCPConfig(true);
103
+ window.open(url, '_blank');
104
+ } catch (error) {
105
+ console.error(error);
106
+ }
107
+ },
108
+ };
60
109
  case 'copy':
61
110
  return {
62
111
  buttonText: translate('page.actions.copyButtonText', 'Copy'),
@@ -121,9 +170,17 @@ export function usePageActions(pageSlug: string): PageAction[] {
121
170
  }
122
171
  })
123
172
  .filter((action) => action !== null);
124
- }, [themeConfig, translate, pageSlug, isPublic, shouldHideAllActions]);
125
-
126
- return actions;
173
+ }, [
174
+ shouldHideAllActions,
175
+ pageSlug,
176
+ themeConfig.navigation?.actions?.items,
177
+ actions,
178
+ mcpUrl,
179
+ translate,
180
+ isPublic,
181
+ ]);
182
+
183
+ return result;
127
184
  }
128
185
 
129
186
  function shouldHidePageActions(
@@ -17,6 +17,7 @@ export {
17
17
  addTrailingSlash,
18
18
  withPathPrefix,
19
19
  } from '../utils/urls';
20
+ export { capitalize } from '../utils/string';
20
21
  export { typedMemo } from '../hoc/typedMemo';
21
22
  export { useMount } from '../hooks/use-mount';
22
23
  export { GlobalStyle } from '../styles/global';
@@ -206,6 +206,9 @@ export type TranslationKey =
206
206
  | 'page.actions.claudeTitle'
207
207
  | 'page.actions.claudeButtonText'
208
208
  | 'page.actions.claudeDescription'
209
+ | 'page.actions.cursorMcpButtonText'
210
+ | 'page.actions.cursorMcpTitle'
211
+ | 'page.actions.cursorMcpDescription'
209
212
  | 'openapi.download.description.title'
210
213
  | 'openapi.info.title'
211
214
  | 'openapi.info.contact.url'
@@ -283,6 +286,16 @@ export type TranslationKey =
283
286
  | 'openapi.schemaCatalogLink.title'
284
287
  | 'openapi.schemaCatalogLink.copyButtonTooltip'
285
288
  | 'openapi.schemaCatalogLink.copiedTooltip'
289
+ | 'openapi.mcp.title'
290
+ | 'openapi.mcp.endpoint'
291
+ | 'openapi.mcp.tools'
292
+ | 'openapi.mcp.protocolVersion'
293
+ | 'openapi.mcp.capabilities'
294
+ | 'openapi.mcp.experimentalCapabilities'
295
+ | 'openapi.mcp.inputSchema'
296
+ | 'openapi.mcp.inputExample'
297
+ | 'openapi.mcp.outputSchema'
298
+ | 'openapi.mcp.outputExample'
286
299
  | 'asyncapi.download.description.title'
287
300
  | 'asyncapi.info.title'
288
301
  | 'graphql.queries'
@@ -1,6 +1,7 @@
1
1
  export type OpenAPIServer = {
2
2
  url: string;
3
3
  description?: string;
4
+ name?: string;
4
5
  variables?: Record<
5
6
  string,
6
7
  {
@@ -0,0 +1,35 @@
1
+ import React from 'react';
2
+ import styled from 'styled-components';
3
+
4
+ import type { IconProps } from '@redocly/theme/icons/types';
5
+
6
+ const Icon = (props: IconProps) => (
7
+ <svg
8
+ width="16"
9
+ height="16"
10
+ viewBox="0 0 16 16"
11
+ fill="none"
12
+ xmlns="http://www.w3.org/2000/svg"
13
+ {...props}
14
+ >
15
+ <path d="M7.99956 15V8.0001L2 11.4999L7.99956 15Z" fill="#939393" />
16
+ <path d="M14 4.49979L7.99956 15V8.0001L14 4.49979Z" fill="#E3E3E3" />
17
+ <path d="M2 4.49979H14L7.99956 8.0001L2 4.49979Z" fill="white" />
18
+ <path d="M8.00025 1V4.49995L14 4.49979L8.00025 1Z" fill="#444444" />
19
+ <path
20
+ d="M2 4.49979L8.00025 4.49995V1L2 4.49979ZM13.9999 11.4998L10.9999 9.74987L7.99956 15L13.9999 11.4998Z"
21
+ fill="#939393"
22
+ />
23
+ <path
24
+ d="M14 4.49979L10.9999 9.74987L13.9999 11.4998L14 4.49979ZM7.99956 8.0001L2 11.4999V4.49979L7.99956 8.0001Z"
25
+ fill="#444444"
26
+ />
27
+ </svg>
28
+ );
29
+
30
+ export const CursorIcon = styled(Icon).attrs(() => ({
31
+ 'data-component-name': 'icons/CursorIcon/CursorIcon',
32
+ }))<IconProps>`
33
+ height: ${({ size }) => size || '16px'};
34
+ width: ${({ size }) => size || '16px'};
35
+ `;
@@ -9,12 +9,7 @@ import { breakpoints } from '@redocly/theme/core/utils';
9
9
  import { PageNavigation } from '@redocly/theme/components/PageNavigation/PageNavigation';
10
10
  import { LastUpdated } from '@redocly/theme/components/LastUpdated/LastUpdated';
11
11
  import { Breadcrumbs as ThemeBreadcrumbs } from '@redocly/theme/components/Breadcrumbs/Breadcrumbs';
12
-
13
- import {
14
- CodeSnippetContext,
15
- CODE_GROUP_SNIPPET_NAME_KEY,
16
- } from '../core/contexts/CodeSnippetContext';
17
- import { useLocalState } from '../core/hooks/use-local-state';
12
+ import { CodeSnippetProvider } from '@redocly/theme/core/contexts/CodeSnippetContext';
18
13
 
19
14
  type DocumentationLayoutProps = {
20
15
  tableOfContent: React.ReactNode;
@@ -44,10 +39,8 @@ export function DocumentationLayout({
44
39
  const { editPage: themeEditPage } = config || {};
45
40
  const mergedConf = editPage ? { ...themeEditPage, ...editPage } : undefined;
46
41
 
47
- const [activeSnippetName, setActiveSnippetName] = useLocalState(CODE_GROUP_SNIPPET_NAME_KEY, '');
48
-
49
42
  return (
50
- <CodeSnippetContext.Provider value={{ activeSnippetName, setActiveSnippetName }}>
43
+ <CodeSnippetProvider>
51
44
  <LayoutWrapper data-component-name="Layout/DocumentationLayout" className={className}>
52
45
  <ContentWrapper withToc={!config?.toc?.hide}>
53
46
  <Breadcrumbs />
@@ -61,7 +54,7 @@ export function DocumentationLayout({
61
54
  </ContentWrapper>
62
55
  {tableOfContent}
63
56
  </LayoutWrapper>
64
- </CodeSnippetContext.Provider>
57
+ </CodeSnippetProvider>
65
58
  );
66
59
  }
67
60
 
@@ -5,65 +5,55 @@ import {
5
5
  type CodeBlockProps,
6
6
  } from '@redocly/theme/components/CodeBlock/CodeBlock';
7
7
  import { langToName } from '@redocly/theme/core/utils';
8
- import { useActiveCodeSnippetName } from '@redocly/theme/core/contexts';
8
+ import { useActiveCodeSnippetId } from '@redocly/theme/core/contexts';
9
+
10
+ type SnippetData = {
11
+ name: string;
12
+ languageName: string;
13
+ lang: string;
14
+ props: CodeBlockProps;
15
+ id: string;
16
+ };
9
17
 
10
18
  export function CodeGroup(props: React.PropsWithChildren<{ mode?: 'tabs' | 'dropdown' }>) {
11
19
  const mode = props.mode || 'tabs';
12
20
  const isTabsMode = mode === 'tabs';
13
21
 
14
22
  const rawSnippets = React.useMemo(
15
- () =>
16
- React.Children.toArray(props.children).map((child, idx) => {
17
- const childProps = child as React.ReactElement<CodeBlockProps>;
18
- return {
19
- name: getTabName(childProps.props, idx),
20
- languageName: langToName(childProps.props.lang || 'Default'),
21
- lang: childProps.props.lang || '',
22
- props: childProps.props,
23
- };
24
- }),
23
+ () => parseSnippetsFromChildren(props.children),
25
24
  [props.children],
26
25
  );
27
26
 
28
- const [activeSnippetName, setActiveSnippetName] = useActiveCodeSnippetName(mode);
29
-
30
- const snippets: Record<string, CodeBlockProps> = React.useMemo(
31
- () =>
32
- Object.fromEntries(
33
- rawSnippets.map((snippet) => {
34
- const getItemName = (snippet: (typeof rawSnippets)[number]) =>
35
- isTabsMode ? snippet?.name : snippet?.languageName || '';
36
-
37
- const name = getItemName(snippet);
38
-
39
- const items = rawSnippets.map((item) => ({
40
- name: getItemName(item),
41
- lang: item.lang,
42
- }));
43
- const itemsProps = {
44
- items,
45
- onChange: (name: string | string[]) => {
46
- setActiveSnippetName(name as string);
47
- },
48
- value: activeSnippetName || getItemName(rawSnippets[0]),
49
- };
50
- const snippetProps = {
51
- ...snippet.props,
52
- header: {
53
- ...snippet.props.header,
54
- title: isTabsMode ? undefined : snippet.name,
55
- },
56
- ...(isTabsMode ? { tabs: itemsProps } : { dropdown: itemsProps }),
57
- };
58
-
59
- return [name, snippetProps];
60
- }),
61
- ),
62
- [rawSnippets, activeSnippetName, isTabsMode, setActiveSnippetName],
63
- );
27
+ const groupId = React.useMemo(() => generateGroupId(rawSnippets, mode), [rawSnippets, mode]);
28
+
29
+ const [activeSnippetId, setActiveSnippetId] = useActiveCodeSnippetId(groupId, rawSnippets);
30
+
31
+ const snippets = React.useMemo(() => {
32
+ const items = createItemsFromSnippets(rawSnippets, isTabsMode);
64
33
 
65
- const firstName = Object.keys(snippets)[0];
66
- const activeSnippet = snippets[activeSnippetName] || snippets[firstName];
34
+ const itemsProps = {
35
+ items,
36
+ onChange: (id: string | string[]) => setActiveSnippetId(id as string),
37
+ value: activeSnippetId,
38
+ };
39
+
40
+ return Object.fromEntries(
41
+ rawSnippets.map((snippet: SnippetData) => {
42
+ const snippetProps = {
43
+ ...snippet.props,
44
+ header: {
45
+ ...snippet.props.header,
46
+ title: isTabsMode ? undefined : snippet.name,
47
+ },
48
+ ...(isTabsMode ? { tabs: itemsProps } : { dropdown: itemsProps }),
49
+ };
50
+
51
+ return [snippet.id, snippetProps];
52
+ }),
53
+ );
54
+ }, [rawSnippets, activeSnippetId, isTabsMode, setActiveSnippetId]);
55
+
56
+ const activeSnippet = snippets[activeSnippetId];
67
57
  if (!activeSnippet) {
68
58
  return null;
69
59
  }
@@ -71,8 +61,47 @@ export function CodeGroup(props: React.PropsWithChildren<{ mode?: 'tabs' | 'drop
71
61
  return <CodeBlockComponent {...activeSnippet} />;
72
62
  }
73
63
 
64
+ function generateContentHash(content: string): number {
65
+ let hash = 0;
66
+ for (let i = 0; i < content.length; i++) {
67
+ hash = content.charCodeAt(i) + ((hash << 5) - hash);
68
+ }
69
+ return Math.abs(hash);
70
+ }
71
+
72
+ // Generate unique group ID for CodeGroup instance
73
+ // Examples: "dropdown-8901234", "tabs-1234567"
74
+ function generateGroupId(rawSnippets: SnippetData[], mode: string): string {
75
+ const content = rawSnippets.map((s) => s.id + (s.props.source || '')).join('|') + `|${mode}`;
76
+ const hash = generateContentHash(content);
77
+
78
+ return `${mode}-${hash}`;
79
+ }
80
+
74
81
  function getTabName(props: CodeBlockProps, idx: number): string {
75
- return String(
76
- props.header?.title || props.file || langToName(props.lang || '') || 'Tab ' + String(idx + 1),
77
- );
82
+ const fallbackName = `Tab ${idx + 1}`;
83
+ return String(props.header?.title || props.file || langToName(props.lang || '') || fallbackName);
84
+ }
85
+
86
+ function parseSnippetsFromChildren(children: React.ReactNode): SnippetData[] {
87
+ return React.Children.toArray(children).map((child, idx) => {
88
+ const childProps = child as React.ReactElement<CodeBlockProps>;
89
+ const props = childProps.props;
90
+
91
+ return {
92
+ name: getTabName(props, idx),
93
+ languageName: String(langToName(props.lang || 'Default') || ''),
94
+ lang: props.lang || '',
95
+ props,
96
+ id: `${props.lang || ''}-${idx}`,
97
+ };
98
+ });
99
+ }
100
+
101
+ function createItemsFromSnippets(snippets: SnippetData[], isTabsMode: boolean) {
102
+ return snippets.map((snippet) => ({
103
+ name: isTabsMode ? snippet.name : snippet.languageName || '',
104
+ lang: snippet.lang,
105
+ id: snippet.id,
106
+ }));
78
107
  }
@@ -104,6 +104,7 @@ export function TabList({
104
104
  key={`more-${tabId}`}
105
105
  active={activeTab === label}
106
106
  onAction={() => {
107
+ childrenArray[index].props.onClick?.();
107
108
  onTabClick(index);
108
109
  }}
109
110
  disabled={childrenArray[index].props.disable}