mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-11 21:39:22 +00:00
Use page actions items list for ordering and default action (#4302)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Drive page actions ordering and default action from the new `pageActions.items` list, including the reorderable assistant action
|
||||
@@ -7,7 +7,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"turbo": "^2.9.15",
|
||||
"turbo": "^2.9.18",
|
||||
"vercel": "50.37.3",
|
||||
},
|
||||
},
|
||||
@@ -360,7 +360,7 @@
|
||||
"react-dom": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "0.183.0",
|
||||
"@gitbook/api": "0.183.1",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -756,7 +756,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.183.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-0+6VyRH7me5AtzU+mwZVFHDaJudtnHYEcxwQ/qO7p2swj7dOe/dOevfaphgx+pUvhXAg7VVlihETaAW/WsLTzQ=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.183.1", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-l9Bse8mLXG9rUR+jTiD/xZYMQ21t4XrxQvvaa7skaibIyBjjnIsNGo7NhVmjESUKHrA6F40ouH36Oiv2DfH+Wg=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.183.0",
|
||||
"@gitbook/api": "0.183.1",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
import { Button, ButtonGroup } from '@/components/primitives/Button';
|
||||
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { GitSyncState, SiteCustomizationSettings } from '@gitbook/api';
|
||||
import React, { useRef } from 'react';
|
||||
import { useAI } from '../AI';
|
||||
import type {
|
||||
CustomizationPageActionType,
|
||||
GitSyncState,
|
||||
SiteCustomizationSettings,
|
||||
} from '@gitbook/api';
|
||||
import { type ReactNode, useRef } from 'react';
|
||||
import { type Assistant, useAI } from '../AI';
|
||||
import { ToggleChevron } from '../primitives';
|
||||
import {
|
||||
ActionCopyMCPCommand,
|
||||
@@ -21,6 +25,32 @@ import {
|
||||
type PageActionAssistantContext,
|
||||
} from './PageActions';
|
||||
|
||||
/**
|
||||
* Type of a built-in page action that can be displayed in the page actions menu.
|
||||
*/
|
||||
type PageActionType = `${CustomizationPageActionType}`;
|
||||
|
||||
/**
|
||||
* Order used to derive the list of actions from the deprecated boolean flags when the API does not
|
||||
* provide `items` yet. It matches the order the page actions menu used before the `items` model, so
|
||||
* existing sites keep the same dropdown ordering until they are migrated.
|
||||
*/
|
||||
const LEGACY_PAGE_ACTION_ORDER: PageActionType[] = [
|
||||
'assistant',
|
||||
'markdown',
|
||||
'external-ai',
|
||||
'mcp',
|
||||
'git',
|
||||
'pdf',
|
||||
];
|
||||
|
||||
/**
|
||||
* Default-button priority used in legacy mode (no `items`). It reproduces the previous behavior,
|
||||
* which only ever surfaced the assistant, the Git edit link or the markdown copy as the default
|
||||
* action — never ChatGPT, MCP or PDF.
|
||||
*/
|
||||
const LEGACY_DEFAULT_ACTION_PRIORITY: PageActionType[] = ['assistant', 'git', 'markdown'];
|
||||
|
||||
export type PageActionsDropdownURLs = {
|
||||
html: string;
|
||||
markdown: string;
|
||||
@@ -43,19 +73,90 @@ interface PageActionsDropdownProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM).
|
||||
* Dropdown menu for the page actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM…).
|
||||
*
|
||||
* The order and enabled state of the built-in actions are driven by `actions.items`, the ordered
|
||||
* list of enabled page actions, with its first available action shown as a quick-access button.
|
||||
* When the API does not provide `items` yet, the menu falls back to the previous ordering and
|
||||
* default-action priority so existing sites keep their current behavior.
|
||||
*/
|
||||
export function PageActionsDropdown(props: PageActionsDropdownProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const language = useLanguage();
|
||||
const { siteTitle, urls } = props;
|
||||
|
||||
const defaultAction = usePageDefaultAction(props);
|
||||
const dropdownActions = getPageDropdownActions(props);
|
||||
const assistants = useAI().assistants.filter(
|
||||
(assistant) => assistant.ui === true && assistant.pageAction
|
||||
);
|
||||
// `items` is the source of truth when the API provides it. Until then (legacy mode), we derive
|
||||
// the list from the deprecated boolean flags using the previous ordering.
|
||||
const configuredItems = getConfiguredPageActionItems(props.actions);
|
||||
const isLegacy = configuredItems === null;
|
||||
const items = configuredItems ?? deriveLegacyPageActionItems(props.actions);
|
||||
|
||||
return defaultAction || dropdownActions.length > 0 ? (
|
||||
let defaultAction: ReactNode = null;
|
||||
let markdownIsDefault = false;
|
||||
if (urls.rss) {
|
||||
// The RSS feed is not part of the configurable `items` list: it is only available on the
|
||||
// relevant pages (e.g. blog/changelog index). It is promoted as the default action whenever
|
||||
// present, as a contextual override of the configured list.
|
||||
defaultAction = <ActionViewAsRSS url={urls.rss} type="button" />;
|
||||
} else {
|
||||
// The default button is the first available action. With `items`, that is simply the first
|
||||
// entry of the configured list. In legacy mode we keep the previous default-action priority
|
||||
// (assistant → Git → markdown), so existing sites don't suddenly surface ChatGPT/MCP/PDF.
|
||||
const defaultPriority = isLegacy ? LEGACY_DEFAULT_ACTION_PRIORITY : items;
|
||||
const defaultActionType = defaultPriority.find(
|
||||
(type) => items.includes(type) && isActionTypeAvailable(type, urls, assistants)
|
||||
);
|
||||
if (defaultActionType) {
|
||||
defaultAction = renderDefaultActionForType(defaultActionType, {
|
||||
siteTitle,
|
||||
urls,
|
||||
assistants,
|
||||
page: props.page,
|
||||
});
|
||||
markdownIsDefault = defaultActionType === 'markdown';
|
||||
}
|
||||
}
|
||||
|
||||
// Build the dropdown menu items, grouped by action type. RSS is appended as its own group
|
||||
// since it is not part of the configurable `items` list.
|
||||
const groups: { key: string; items: ReactNode[] }[] = items
|
||||
.map((type) => ({
|
||||
key: type,
|
||||
items: renderDropdownActionsForType(type, {
|
||||
siteTitle,
|
||||
urls,
|
||||
markdownIsDefault,
|
||||
assistants,
|
||||
page: props.page,
|
||||
}),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
|
||||
if (urls.rss) {
|
||||
groups.push({
|
||||
key: 'rss',
|
||||
items: [<ActionViewAsRSS key="rss" url={urls.rss} type="dropdown-menu-item" />],
|
||||
});
|
||||
}
|
||||
|
||||
// Count the actual menu items (not the groups): the dropdown toggle must stay visible when a
|
||||
// single action type still exposes more than one item beyond the default button (e.g. markdown
|
||||
// exposes both "Copy page" and "View as Markdown").
|
||||
const menuItemCount = groups.reduce((total, group) => total + group.items.length, 0);
|
||||
|
||||
// Insert a separator before each group; the leading one is hidden via `first:hidden`.
|
||||
const dropdownActions = groups.flatMap((group) => [
|
||||
<DropdownMenuSeparator key={`separator-${group.key}`} className="first:hidden" />,
|
||||
...group.items,
|
||||
]);
|
||||
|
||||
return defaultAction || menuItemCount > 0 ? (
|
||||
<ButtonGroup ref={ref} className={props.className}>
|
||||
{defaultAction}
|
||||
{!defaultAction || dropdownActions.length > 1 ? (
|
||||
{!defaultAction || menuItemCount > 1 ? (
|
||||
<DropdownMenu
|
||||
align="end"
|
||||
className="!min-w-60 max-w-max"
|
||||
@@ -78,123 +179,212 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of actions to show in the dropdown menu.
|
||||
* Return the configured ordered list of enabled page actions, or `null` when the API does not
|
||||
* provide it yet (legacy mode).
|
||||
*/
|
||||
function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNode[] {
|
||||
const { siteTitle, urls, actions, page } = props;
|
||||
const assistants = useAI().assistants.filter(
|
||||
(assistant) => assistant.ui === true && assistant.pageAction
|
||||
);
|
||||
function getConfiguredPageActionItems(
|
||||
actions: SiteCustomizationSettings['pageActions']
|
||||
): PageActionType[] | null {
|
||||
return actions.items ?? null;
|
||||
}
|
||||
|
||||
return [
|
||||
...assistants.map((assistant) => (
|
||||
<ActionOpenAssistant
|
||||
key={assistant.label}
|
||||
assistant={assistant}
|
||||
type="dropdown-menu-item"
|
||||
page={page}
|
||||
/>
|
||||
)),
|
||||
/**
|
||||
* Derive the ordered list of enabled page actions from the deprecated boolean flags, following the
|
||||
* ordering used before the `items` model. Used only when the API does not provide `items`.
|
||||
*/
|
||||
function deriveLegacyPageActionItems(
|
||||
actions: SiteCustomizationSettings['pageActions']
|
||||
): PageActionType[] {
|
||||
return LEGACY_PAGE_ACTION_ORDER.filter((type) => {
|
||||
switch (type) {
|
||||
case 'external-ai':
|
||||
return actions.externalAI;
|
||||
case 'markdown':
|
||||
return actions.markdown;
|
||||
case 'mcp':
|
||||
return actions.mcp;
|
||||
// `assistant` is governed by the AI mode setting, and `git`/`pdf` are not represented
|
||||
// by the legacy `pageActions` flags; all three are gated by availability at render time.
|
||||
case 'assistant':
|
||||
case 'git':
|
||||
case 'pdf':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
actions.markdown ? (
|
||||
<React.Fragment key="markdown">
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
/**
|
||||
* Whether an action type can be rendered given the available URLs and assistants.
|
||||
*/
|
||||
function isActionTypeAvailable(
|
||||
type: PageActionType,
|
||||
urls: PageActionsDropdownURLs,
|
||||
assistants: Assistant[]
|
||||
): boolean {
|
||||
switch (type) {
|
||||
case 'assistant':
|
||||
return assistants.length > 0;
|
||||
case 'external-ai':
|
||||
case 'markdown':
|
||||
return true;
|
||||
case 'mcp':
|
||||
return !!urls.mcp;
|
||||
case 'git':
|
||||
return !!urls.editOnGit;
|
||||
case 'pdf':
|
||||
return !!urls.pdf;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the list of menu items shown in the dropdown for a given action type.
|
||||
*
|
||||
* Returns a flat array of items (without separators); the caller groups them and inserts the
|
||||
* separators between groups.
|
||||
*/
|
||||
function renderDropdownActionsForType(
|
||||
type: PageActionType,
|
||||
params: {
|
||||
siteTitle: string;
|
||||
urls: PageActionsDropdownURLs;
|
||||
markdownIsDefault: boolean;
|
||||
assistants: Assistant[];
|
||||
page: PageActionAssistantContext;
|
||||
}
|
||||
): ReactNode[] {
|
||||
const { siteTitle, urls, markdownIsDefault, assistants, page } = params;
|
||||
|
||||
switch (type) {
|
||||
case 'assistant':
|
||||
return assistants.map((assistant) => (
|
||||
<ActionOpenAssistant
|
||||
key={`assistant-${assistant.id}`}
|
||||
assistant={assistant}
|
||||
type="dropdown-menu-item"
|
||||
page={page}
|
||||
/>
|
||||
));
|
||||
case 'external-ai':
|
||||
return [
|
||||
<ActionOpenInLLM
|
||||
key="chatgpt"
|
||||
provider="chatgpt"
|
||||
url={urls.html}
|
||||
type="dropdown-menu-item"
|
||||
/>,
|
||||
<ActionOpenInLLM
|
||||
key="claude"
|
||||
provider="claude"
|
||||
url={urls.html}
|
||||
type="dropdown-menu-item"
|
||||
/>,
|
||||
];
|
||||
case 'markdown':
|
||||
return [
|
||||
<ActionCopyMarkdown
|
||||
isDefaultAction={!assistants.length}
|
||||
key="copy-markdown"
|
||||
isDefaultAction={markdownIsDefault}
|
||||
markdownPageURL={urls.markdown}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
<ActionViewAsMarkdown markdownPageURL={urls.markdown} type="dropdown-menu-item" />
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
|
||||
actions.externalAI ? (
|
||||
<React.Fragment key="externalAI">
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
<ActionOpenInLLM provider="chatgpt" url={urls.html} type="dropdown-menu-item" />
|
||||
<ActionOpenInLLM provider="claude" url={urls.html} type="dropdown-menu-item" />
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
|
||||
actions.mcp && urls.mcp ? (
|
||||
<React.Fragment key="mcp">
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
<ActionCopyMCPURL mcpURL={urls.mcp} type="dropdown-menu-item" />
|
||||
/>,
|
||||
<ActionViewAsMarkdown
|
||||
key="view-markdown"
|
||||
markdownPageURL={urls.markdown}
|
||||
type="dropdown-menu-item"
|
||||
/>,
|
||||
];
|
||||
case 'mcp':
|
||||
if (!urls.mcp) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
<ActionCopyMCPURL key="copy-mcp" mcpURL={urls.mcp} type="dropdown-menu-item" />,
|
||||
<ActionOpenMCP
|
||||
key="mcp-vscode"
|
||||
provider="vscode"
|
||||
mcpURL={urls.mcp}
|
||||
siteTitle={siteTitle}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
/>,
|
||||
<ActionCopyMCPCommand
|
||||
key="mcp-claude-code"
|
||||
provider="claude-code"
|
||||
mcpURL={urls.mcp}
|
||||
siteTitle={siteTitle}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
/>,
|
||||
<ActionCopyMCPCommand
|
||||
key="mcp-codex"
|
||||
provider="codex"
|
||||
mcpURL={urls.mcp}
|
||||
siteTitle={siteTitle}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
|
||||
urls.editOnGit || urls.pdf || urls.rss ? (
|
||||
<React.Fragment key="editOnGit">
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
{urls.editOnGit ? (
|
||||
<ActionOpenEditOnGit
|
||||
type="dropdown-menu-item"
|
||||
provider={urls.editOnGit.provider}
|
||||
url={urls.editOnGit.url}
|
||||
/>
|
||||
) : null}
|
||||
{urls.rss ? <ActionViewAsRSS url={urls.rss} type="dropdown-menu-item" /> : null}
|
||||
{urls.pdf ? <ActionViewAsPDF url={urls.pdf} type="dropdown-menu-item" /> : null}
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
].filter(Boolean);
|
||||
/>,
|
||||
];
|
||||
case 'git':
|
||||
if (!urls.editOnGit) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
<ActionOpenEditOnGit
|
||||
key="edit-git"
|
||||
type="dropdown-menu-item"
|
||||
provider={urls.editOnGit.provider}
|
||||
url={urls.editOnGit.url}
|
||||
/>,
|
||||
];
|
||||
case 'pdf':
|
||||
if (!urls.pdf) {
|
||||
return [];
|
||||
}
|
||||
return [<ActionViewAsPDF key="pdf" url={urls.pdf} type="dropdown-menu-item" />];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A default action shown as a quick-access button beside the dropdown menu
|
||||
* Render the action shown as the quick-access default button for a given action type.
|
||||
*/
|
||||
function usePageDefaultAction(props: PageActionsDropdownProps) {
|
||||
const { urls, actions, page } = props;
|
||||
const assistants = useAI().assistants.filter(
|
||||
(assistant) => assistant.ui === true && assistant.pageAction
|
||||
);
|
||||
|
||||
if (urls.rss) {
|
||||
return <ActionViewAsRSS url={urls.rss} type="button" />;
|
||||
function renderDefaultActionForType(
|
||||
type: PageActionType,
|
||||
params: {
|
||||
siteTitle: string;
|
||||
urls: PageActionsDropdownURLs;
|
||||
assistants: Assistant[];
|
||||
page: PageActionAssistantContext;
|
||||
}
|
||||
): ReactNode {
|
||||
const { urls, assistants, page } = params;
|
||||
|
||||
const assistant = assistants[0];
|
||||
if (assistant) {
|
||||
return <ActionOpenAssistant assistant={assistant} type="button" page={page} />;
|
||||
switch (type) {
|
||||
case 'assistant':
|
||||
return assistants[0] ? (
|
||||
<ActionOpenAssistant assistant={assistants[0]} type="button" page={page} />
|
||||
) : null;
|
||||
case 'external-ai':
|
||||
return <ActionOpenInLLM provider="chatgpt" url={urls.html} type="button" />;
|
||||
case 'markdown':
|
||||
return (
|
||||
<ActionCopyMarkdown isDefaultAction markdownPageURL={urls.markdown} type="button" />
|
||||
);
|
||||
case 'mcp':
|
||||
return urls.mcp ? <ActionCopyMCPURL mcpURL={urls.mcp} type="button" /> : null;
|
||||
case 'git':
|
||||
return urls.editOnGit ? (
|
||||
<ActionOpenEditOnGit
|
||||
type="button"
|
||||
provider={urls.editOnGit.provider}
|
||||
url={urls.editOnGit.url}
|
||||
/>
|
||||
) : null;
|
||||
case 'pdf':
|
||||
return urls.pdf ? <ActionViewAsPDF url={urls.pdf} type="button" /> : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
if (urls.editOnGit) {
|
||||
return (
|
||||
<ActionOpenEditOnGit
|
||||
type="button"
|
||||
provider={urls.editOnGit.provider}
|
||||
url={urls.editOnGit.url}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (actions.markdown) {
|
||||
return (
|
||||
<ActionCopyMarkdown
|
||||
isDefaultAction={!assistant}
|
||||
markdownPageURL={urls.markdown}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { GitBookSiteContext } from '@/lib/context';
|
||||
import type { AncestorRevisionPage } from '@/lib/pages';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getPageRSSURL } from '@/routes/rss';
|
||||
import { type RevisionPageDocument, SiteVisibility } from '@gitbook/api';
|
||||
import { CustomizationAIMode, type RevisionPageDocument, SiteVisibility } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import urlJoin from 'url-join';
|
||||
import { getPDFURLSearchParams } from '../PDF';
|
||||
@@ -33,7 +33,10 @@ export async function PageHeader(props: {
|
||||
const hasPageActions =
|
||||
pageActionsEnabled &&
|
||||
[
|
||||
...Object.values(context.customization.pageActions),
|
||||
context.customization.ai.mode === CustomizationAIMode.Assistant,
|
||||
context.customization.pageActions.externalAI,
|
||||
context.customization.pageActions.markdown,
|
||||
context.customization.pageActions.mcp,
|
||||
context.customization.pdf.enabled,
|
||||
context.customization.git.showEditLink,
|
||||
withRSSFeed,
|
||||
|
||||
Reference in New Issue
Block a user