@redocly/theme
Version:
Shared UI components lib
357 lines (324 loc) • 11.1 kB
text/typescript
import { useMemo, useCallback } from 'react';
import type { PageProps, UiAccessibleConfig } from '@redocly/config';
import type { PageAction, MCPClientType, McpConnectionParams } from '../types';
import { CopyIcon } from '@redocly/theme/icons/CopyIcon/CopyIcon';
import { ChatGptIcon } from '@redocly/theme/icons/ChatGptIcon/ChatGptIcon';
import { ClaudeIcon } from '@redocly/theme/icons/ClaudeIcon/ClaudeIcon';
import { MarkdownFullIcon } from '@redocly/theme/icons/MarkdownFullIcon/MarkdownFullIcon';
import { VSCodeIcon } from '@redocly/theme/icons/VSCodeIcon/VSCodeIcon';
import { CursorIcon } from '@redocly/theme/icons/CursorIcon/CursorIcon';
import { useThemeHooks } from './use-theme-hooks';
import { useThemeConfig } from './use-theme-config';
import { useMCPConfig } from './use-mcp-config';
import { ClipboardService } from '../utils/clipboard-service';
import { IS_BROWSER } from '../utils/dom';
import { generateMCPDeepLink } from '../utils/mcp';
import {
addTrailingSlash,
combineUrls,
removeTrailingSlash,
withoutPathPrefix,
} from '../utils/urls';
function createPageActionResource(pageSlug: string, pageUrl: string) {
return {
id: pageSlug,
object: 'page' as const,
uri: pageUrl,
};
}
const DEFAULT_ENABLED_ACTIONS = [
'copy',
'view',
'chatgpt',
'claude',
'docs-mcp-cursor',
'docs-mcp-vscode',
] as const;
export type PageActionType =
| 'copy'
| 'view'
| 'chatgpt'
| 'claude'
| 'docs-mcp-cursor'
| 'docs-mcp-vscode'
| 'mcp-cursor'
| 'mcp-vscode';
export function usePageActions(
pageSlug: string,
mcpUrl?: string,
actions?: PageActionType[],
): PageAction[] {
const { useTranslate, usePageData, usePageProps, usePageSharedData, useTelemetry } =
useThemeHooks();
const { translate } = useTranslate();
const themeConfig = useThemeConfig();
const pageProps = usePageProps();
const telemetry = useTelemetry();
const openApiSharedData = usePageSharedData<
{ options: { excludeFromSearch: boolean } } | undefined
>('openAPIDocsStore');
const openapiExcludeFromSearch = openApiSharedData?.options?.excludeFromSearch;
const mcpConfig = useMCPConfig();
const { isPublic } = usePageData() || {};
const createMCPHandler = useCallback(
(clientType: MCPClientType, requiresMcpUrl: boolean = false) =>
(): PageAction => {
const config = requiresMcpUrl
? { serverName: mcpConfig.serverName, url: mcpUrl || '' }
: { serverName: mcpConfig.serverName, url: mcpConfig.serverUrl || '' };
const isDocsMcp = !requiresMcpUrl;
const origin = IS_BROWSER ? window.location.origin : '';
const pageUrl = `${origin}${pageSlug}`;
return createMCPAction({
clientType,
mcpConfig: config,
translate,
onClickCallback: isDocsMcp
? () =>
telemetry.sendPageActionsButtonClickedMessage([
{
...createPageActionResource(pageSlug, pageUrl),
action_type: `docs-mcp-${clientType}` as const,
},
])
: undefined,
});
},
[mcpUrl, mcpConfig, translate, telemetry, pageSlug],
);
const result: PageAction[] = useMemo(() => {
const hideActionContext: ShouldHideActionContext = {
pageProps,
themeConfig,
openapiExcludeFromSearch,
isPublic,
mcpUrl,
isMcpDisabled: mcpConfig.isMcpDisabled,
};
const origin = IS_BROWSER
? window.location.origin
: ((globalThis as { SSR_HOSTNAME?: string })['SSR_HOSTNAME'] ?? '');
const pathname = addTrailingSlash(pageSlug);
const pageUrl = combineUrls(origin, pathname);
const isRoot = withoutPathPrefix(pathname) === '/';
const mdPageUrl = isRoot
? combineUrls(origin, pathname, 'index.html.md')
: combineUrls(origin, removeTrailingSlash(pathname) + '.md');
const actionHandlers: Record<PageActionType, () => PageAction> = {
'docs-mcp-cursor': createMCPHandler('cursor', false),
'docs-mcp-vscode': createMCPHandler('vscode', false),
'mcp-cursor': createMCPHandler('cursor', true),
'mcp-vscode': createMCPHandler('vscode', true),
copy: () => ({
buttonText: translate('page.actions.copyButtonText', 'Copy'),
title: translate('page.actions.copyTitle', 'Copy for LLM'),
description: translate('page.actions.copyDescription', 'Copy page as Markdown for LLMs'),
iconComponent: CopyIcon,
onClick: async () => {
try {
const result = await fetch(mdPageUrl);
if (result.status !== 200) {
return;
}
const text = await result.text();
ClipboardService.copyCustom(text);
telemetry.sendPageActionsButtonClickedMessage([
{
...createPageActionResource(pageSlug, pageUrl),
action_type: 'copy',
},
]);
} catch (error) {
console.error(error);
}
},
}),
view: () => ({
buttonText: translate('page.actions.viewAsMdButtonText', 'View as Markdown'),
title: translate('page.actions.viewAsMdTitle', 'View as Markdown'),
description: translate('page.actions.viewAsMdDescription', 'Open this page as Markdown'),
iconComponent: MarkdownFullIcon,
link: mdPageUrl,
onClick: () => {
telemetry.sendPageActionsButtonClickedMessage([
{
...createPageActionResource(pageSlug, pageUrl),
action_type: 'view',
},
]);
},
}),
chatgpt: () => {
const link = getExternalAiPromptLink('https://chat.openai.com', mdPageUrl);
return {
buttonText: translate('page.actions.chatGptButtonText', 'Open in ChatGPT'),
title: translate('page.actions.chatGptTitle', 'Open in ChatGPT'),
description: translate('page.actions.chatGptDescription', 'Get insights from ChatGPT'),
iconComponent: ChatGptIcon,
link,
onClick: () => {
telemetry.sendPageActionsButtonClickedMessage([
{
...createPageActionResource(pageSlug, pageUrl),
action_type: 'chatgpt',
},
]);
window.location.href = link;
},
};
},
claude: () => {
const link = getExternalAiPromptLink('https://claude.ai/new', mdPageUrl);
return {
buttonText: translate('page.actions.claudeButtonText', 'Open in Claude'),
title: translate('page.actions.claudeTitle', 'Open in Claude'),
description: translate('page.actions.claudeDescription', 'Get insights from Claude'),
iconComponent: ClaudeIcon,
link,
onClick: () => {
telemetry.sendPageActionsButtonClickedMessage([
{
...createPageActionResource(pageSlug, pageUrl),
action_type: 'claude',
},
]);
window.location.href = link;
},
};
},
};
return (themeConfig.navigation?.actions?.items || actions || DEFAULT_ENABLED_ACTIONS)
.filter((action) => !shouldHideAction(action, hideActionContext))
.map((action) => actionHandlers[action]());
}, [
pageProps,
themeConfig,
openapiExcludeFromSearch,
isPublic,
mcpUrl,
mcpConfig.isMcpDisabled,
pageSlug,
actions,
translate,
createMCPHandler,
telemetry,
]);
return result;
}
function getExternalAiPromptLink(baseUrl: string, mdPageUrl: string): string {
const externalAiPrompt = `Read ${mdPageUrl} and answer questions based on the content.`;
const url = new URL(baseUrl);
url.searchParams.set('q', externalAiPrompt);
return url.toString();
}
type CreateMCPActionParams = {
clientType: MCPClientType;
mcpConfig: McpConnectionParams;
translate: (key: string, defaultValue: string) => string;
onClickCallback?: () => void;
};
function createMCPAction({
clientType,
mcpConfig,
translate,
onClickCallback,
}: CreateMCPActionParams): PageAction {
const url = generateMCPDeepLink(clientType, mcpConfig);
const sharedProps = {
onClick: () => {
onClickCallback?.();
window.open(url, '_blank');
},
};
if (clientType === 'cursor') {
return {
...sharedProps,
buttonText: translate('page.actions.connectMcp.cursor', 'Connect to Cursor'),
title: translate('page.actions.connectMcp.cursor', 'Connect to Cursor'),
description: translate(
'page.actions.connectMcp.cursorDescription',
'Install MCP server on Cursor',
),
iconComponent: CursorIcon,
};
}
return {
...sharedProps,
buttonText: translate('page.actions.connectMcp.vscode', 'Connect to VS Code'),
title: translate('page.actions.connectMcp.vscode', 'Connect to VS Code'),
description: translate(
'page.actions.connectMcp.vscodeDescription',
'Install MCP server on VS Code',
),
iconComponent: VSCodeIcon,
};
}
type ShouldHideActionContext = {
pageProps: PageProps;
themeConfig: UiAccessibleConfig;
openapiExcludeFromSearch?: boolean;
isPublic?: boolean;
mcpUrl?: string;
isMcpDisabled: boolean;
};
function shouldHideAction(
action: PageActionType,
{
pageProps,
themeConfig,
openapiExcludeFromSearch,
isPublic,
mcpUrl,
isMcpDisabled,
}: ShouldHideActionContext,
): boolean {
// Can't use any actions if search is globally disabled (markdown files are not generated)
if (themeConfig.search?.hide) {
return true;
}
// Can't use any actions if no markdown files are generated for LLMs
if (pageProps?.seo?.llmstxt?.hide) {
return true;
}
// Page actions are explicitly disabled in config
if (themeConfig.navigation?.actions?.hide) {
return true;
}
const isOpenApiPage =
pageProps?.metadata?.type === 'openapi' || pageProps?.metadata?.subType === 'openapi-operation';
const isPageExcludedFromSearch =
pageProps?.frontmatter?.excludeFromSearch || (isOpenApiPage && openapiExcludeFromSearch);
// Page excluded from search: only explicit MCP connect actions remain visible,
// since they don't depend on the page's markdown.
if (isPageExcludedFromSearch && action !== 'mcp-cursor' && action !== 'mcp-vscode') {
return true;
}
return shouldHideActionByType(action, { isPublic, mcpUrl, isMcpDisabled });
}
function shouldHideActionByType(
action: PageActionType,
{
isPublic,
mcpUrl,
isMcpDisabled,
}: Pick<ShouldHideActionContext, 'isPublic' | 'mcpUrl' | 'isMcpDisabled'>,
): boolean {
switch (action) {
case 'chatgpt':
case 'claude':
return !isPublic;
case 'docs-mcp-cursor':
case 'docs-mcp-vscode':
return Boolean(mcpUrl) || isMcpDisabled;
case 'mcp-cursor':
case 'mcp-vscode':
return !mcpUrl;
case 'copy':
case 'view':
return false;
default: {
action satisfies never;
return true;
}
}
}