@redocly/theme
Version:
Shared UI components lib
320 lines (286 loc) • 10.2 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 mcpConfig = useMCPConfig();
const shouldHideAllActions = shouldHidePageActions(
pageProps,
themeConfig,
openApiSharedData?.options?.excludeFromSearch,
);
const { isPublic } = usePageData() || {};
const createMCPHandler = useCallback(
(clientType: MCPClientType, requiresMcpUrl: boolean = false) =>
() => {
if (requiresMcpUrl && !mcpUrl) return null;
if (!requiresMcpUrl && (mcpUrl || mcpConfig.isMcpDisabled)) return null;
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(() => {
if (shouldHideAllActions) {
return [];
}
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 | null> = {
'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: () => {
if (!isPublic) {
return null;
}
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: () => {
if (!isPublic) {
return null;
}
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)
.map((action) => actionHandlers[action]?.())
.filter((action): action is PageAction => action !== null);
}, [
shouldHideAllActions,
pageSlug,
themeConfig.navigation?.actions?.items,
actions,
translate,
isPublic,
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,
};
}
function shouldHidePageActions(
pageProps: PageProps,
themeConfig: UiAccessibleConfig,
openapiExcludeFromSearch?: boolean,
): 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;
}
// Page is excluded from search
const isOpenApiPage =
pageProps?.metadata?.type === 'openapi' || pageProps?.metadata?.subType === 'openapi-operation';
const isPageExcludedFromSearch =
pageProps?.frontmatter?.excludeFromSearch || (isOpenApiPage && openapiExcludeFromSearch);
if (isPageExcludedFromSearch) {
return true;
}
return false;
}