UNPKG

@redocly/theme

Version:

Shared UI components lib

320 lines (286 loc) 10.2 kB
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; }