Compare commits

..

51 Commits

Author SHA1 Message Date
Nicolas Dorseuil bb46b5b38b Make href prop optional in SubHeaderLinkItem and update StructurePreview to remove default href values 2026-06-24 20:23:30 +02:00
Nicolas Dorseuil 8a9f163d31 Add SiteSectionList to StructurePreviewVariantSelector for improved section display 2026-06-24 20:14:18 +02:00
Nicolas Dorseuil 7c365f9162 Add social account links to StructurePreviewMoreMenu component 2026-06-24 20:11:48 +02:00
Nicolas Dorseuil 10a7095182 Add metadata export to Page component for SEO configuration 2026-06-24 19:56:36 +02:00
conico974 614a50373a Merge branch 'main' into conico/structure-preview 2026-06-24 10:47:51 +02:00
Nolann B. d172684b88 Some small OpenAPI design tweaks (#4337) 2026-06-23 21:20:28 +02:00
Nicolas Dorseuil 7646cb301c Add GITBOOK_APP_URL prop to StructurePreview component and update Page to pass it 2026-06-23 20:44:16 +02:00
Nicolas Dorseuil d2ce6adeb6 Merge remote-tracking branch 'origin/main' into conico/structure-preview 2026-06-23 18:48:26 +02:00
conico974 937c8b8eb2 Remove 'remote' from cache usage in specific functions (#4312) 2026-06-23 17:59:43 +02:00
Zeno Kapitein 36dbdb48cc Support agent goal query param for markdownAsk (#4324)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:47:51 +02:00
Zeno Kapitein 2dc76a2c3c Fix empty grouped section dropdown in embedded sites (#4334)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:47:15 +02:00
Samy Pessé 0edc378c57 Version Packages (#4306)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-23 00:10:23 +02:00
Zeno Kapitein 2eb313194a Remove old PageAction checks (#4333) 2026-06-22 17:12:35 +02:00
Brett Jephson 882dfcda83 Tidy relating to prompt (#4332) 2026-06-22 15:48:58 +02:00
Brett Jephson 3ff88ba22f Adds the prompt block (#4329) 2026-06-22 11:53:25 +01:00
Nolann B. 51bd768042 Make the AI Assistant panel resizable (#4330) 2026-06-20 21:41:59 +02:00
spastorelli daadd91eba Fix NextLink prefetch side effect on unsigned claims persistance (#4325) 2026-06-19 11:39:12 +02:00
Nolann B. f4469b2df3 Support OpenAPI one-operation-per-page mode (#4314) 2026-06-19 10:35:09 +02:00
Nolann B. 91b325ca08 Add AI chat reference button on text selection (#4326) 2026-06-19 10:18:07 +02:00
Nolann B. 1616028426 Align code block action buttons vertically with the block (#4327) 2026-06-19 10:03:37 +02:00
Nolann B. 6c15d8314a Update OpenAPI border radius (#4323) 2026-06-18 23:25:19 +02:00
Nicolas Dorseuil 63e4968987 Enhance StructurePreview message handling by validating event origin against GITBOOK_APP_URL 2026-06-18 16:33:48 +02:00
Zeno Kapitein 6c967c9bcc Add search and filters to table blocks (#4322)
Co-authored-by: utkuufuk <utkuufuk@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:41:40 +02:00
Nicolas Dorseuil 6eb3d3b4e5 Refactor StructurePreview by removing obsolete test files and updating middleware for demo routing 2026-06-17 18:40:08 +02:00
Nicolas Dorseuil 2ba90833a3 fix typescript 2026-06-17 18:15:23 +02:00
Johan Preynat c44d822fe1 Trust cookieless integration content host for WebFrames (#4321)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 18:08:25 +02:00
Zeno Kapitein b0553d5fc8 Skeletons and layout 2026-06-17 17:18:12 +02:00
Zeno Kapitein af3cda1c53 Fix wide content overflowing hint blocks (#4320)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:32:53 +02:00
Nicolas Dorseuil a06da70f5f Refactor StructurePreview to handle partial updates and navigation messages 2026-06-17 11:41:06 +02:00
Tomek a0ad403a91 Stabilize Playwright Chrome text rendering across platforms (#4318) 2026-06-17 09:21:16 +00:00
Zeno Kapitein 0ca49523a2 Remove external link button from link tooltip hover card (#4316)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:52:02 +02:00
Zeno Kapitein 76550415ac Fix ⌘-C opening the Assistant on Dvorak keyboard layouts (#4311)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:51:20 +02:00
Nicolas Dorseuil 53ec8b9f29 Enhance StructurePreview navigation by adding sectionId handling and updating state management 2026-06-17 10:30:14 +02:00
Nicolas Dorseuil 6f63c00300 Refactor StructurePreview components and state management to reduce client side data 2026-06-17 10:13:40 +02:00
Nicolas Dorseuil c334ea2748 linting 2026-06-16 17:38:28 +02:00
Nicolas Dorseuil d75cdf1334 small comment 2026-06-16 17:38:12 +02:00
Nicolas Dorseuil cb649c55cb Commit to revert, just to demonstrate what we can do directly in GBO 2026-06-16 17:38:02 +02:00
Nicolas Dorseuil 205c512cb6 Refactor variant categorization in StructurePreview to utilize categorizeVariants function for improved clarity and maintainability 2026-06-16 16:53:32 +02:00
Nicolas Dorseuil 334a31d7fc Refactor TableOfContents styles and components for improved class management and responsiveness 2026-06-16 16:37:56 +02:00
Nicolas Dorseuil f157cfc9a7 Add disableAnimations prop to SiteSectionTabs for improved rendering control 2026-06-16 16:24:34 +02:00
Nolann B. 509389c7e5 Update @gitbook/api to latest version (#4315) 2026-06-16 14:30:10 +02:00
Nicolas Dorseuil 3485c0192b Refactor StructurePreviewLogoImage to utilize Image component for improved logo rendering and responsiveness 2026-06-16 13:40:54 +02:00
Nicolas Dorseuil 27d9ad6fe4 Refactor Header and StructurePreview components to utilize HeaderLayout for improved structure and consistency in header rendering 2026-06-16 13:31:47 +02:00
Nicolas Dorseuil 96602eb552 Refactor AIChatButton and StructurePreview components to enhance AI assistant integration, improving button rendering and functionality 2026-06-16 13:08:59 +02:00
Nicolas Dorseuil 17d092acdc Refactor Search components to enhance structure and functionality, introducing SearchHeaderInput for improved search input handling 2026-06-16 13:01:25 +02:00
Nicolas Dorseuil 107018f570 Refactor SpacesDropdown and related components for improved structure and functionality 2026-06-16 12:49:15 +02:00
Nicolas Dorseuil 3e152b0662 Refactor layout and page components to streamline structure and enhance theme handling 2026-06-16 12:15:26 +02:00
Nicolas Dorseuil 6a8720894d Refactor header link components for improved structure and styling consistency 2026-06-16 12:05:36 +02:00
Nicolas Dorseuil 8a5ed60c26 Refactor StructurePreview and HeaderLogo components to utilize HeaderLogoContent for improved logo rendering and fallback handling 2026-06-16 11:45:02 +02:00
Nicolas Dorseuil 52fc156b06 Refactor StructurePreview component and related state management by removing unused viewport mode logic and simplifying imports 2026-06-16 11:35:54 +02:00
Nicolas Dorseuil 46091b65d1 Add Structure Preview component and related functionality 2026-06-15 13:40:27 +02:00
162 changed files with 4783 additions and 769 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Mark the AI Assistant chat as `aria-busy` while it is generating a response (including the follow-up suggestion phase), so assistive technologies are notified of the in-progress state.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix the AI assistant panel overflowing its frame when rendering single/multi-choice question forms with long option text.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Assistant: group all of a turn's tool calls behind a single "Explored with N tools" expandable instead of rendering a separate single-item expandable for each client-side tool call.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Support an optional `goal` query parameter on the markdown ask interface (`?ask=…&goal=…`), letting agents describe the broader end goal they are working towards so the answer can be steered towards it.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Drive page actions ordering and default action from the new `pageActions.items` list, including the reorderable assistant action
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Add a page reference chip when opening the Assistant from the page action, so the assistant is informed about the page the reader is currently on.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Keep secondary buttons' background in the flat depth style so they stay visible (instead of becoming transparent).
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix grouped top-nav section dropdowns rendering empty when the site is embedded in an iframe (visitor-auth embeds, editor preview) or shown in the embeddable view. The dropdown viewport is composited and animated, and a clipped composited layer fails to rasterize its text in Chromium when painted inside a sub compositing root; the rounded-corner clipping is now done on an inner wrapper so the viewport itself is no longer clipped.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Keep the page actions bar ("On this page" and the assistant/page actions) pinned to the top of the viewport while scrolling on non-mobile viewports, so it stays reachable on long pages such as API references.
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/react-openapi": patch
---
Handle circular oneOf with discriminator and allOf in OpenAPI schemas
@@ -55,6 +55,7 @@ runs:
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
@@ -57,6 +57,7 @@ runs:
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
+4 -4
View File
@@ -109,7 +109,7 @@
},
"packages/gitbook": {
"name": "gitbook",
"version": "0.27.0",
"version": "0.27.1",
"dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
"@gitbook/api": "catalog:",
@@ -312,7 +312,7 @@
},
"packages/react-openapi": {
"name": "@gitbook/react-openapi",
"version": "1.5.13",
"version": "1.5.14",
"dependencies": {
"@gitbook/expr": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
@@ -360,7 +360,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.183.1",
"@gitbook/api": "0.185.0",
"@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.1", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-l9Bse8mLXG9rUR+jTiD/xZYMQ21t4XrxQvvaa7skaibIyBjjnIsNGo7NhVmjESUKHrA6F40ouH36Oiv2DfH+Wg=="],
"@gitbook/api": ["@gitbook/api@0.185.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-RrFZSHI7W79ri5jHd/gp01ZbuxfOPOFEqnom956wNUfUf/CIQDZcCNzmvLIWzuYesI7Snq3NLi+U5XzOsJhC4g=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
+1 -1
View File
@@ -43,7 +43,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.183.1",
"@gitbook/api": "0.185.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+21
View File
@@ -1,5 +1,26 @@
# gitbook
## 0.27.1
### Patch Changes
- acf2719: Mark the AI Assistant chat as `aria-busy` while it is generating a response (including the follow-up suggestion phase), so assistive technologies are notified of the in-progress state.
- 72a1596: Fix the AI assistant panel overflowing its frame when rendering single/multi-choice question forms with long option text.
- 77c717c: Assistant: group all of a turn's tool calls behind a single "Explored with N tools" expandable instead of rendering a separate single-item expandable for each client-side tool call.
- c44d822: Trust an optional cookieless integration content host (`GITBOOK_INTEGRATIONS_CONTENT_HOST`) for WebFrame postMessage, in preparation for isolating integration-rendered content onto a separate origin. Defaults to `GITBOOK_INTEGRATIONS_HOST`, so behavior is unchanged until the content host is configured.
- 7655041: Match keyboard shortcuts by the logical character typed instead of the physical key position, so that ⌘-C no longer opens the Assistant on the Dvorak layout (and other non-QWERTY layouts).
- af3cda1: Fix wide content (such as code blocks and tables) overflowing the edges of hint blocks instead of scrolling within them.
- 0ca4952: Remove the external link button from the link tooltip hover card, as its arrow read as clickable even though the preview card isn't interactive.
- 3ff88ba: Add a Prompt block
- 251630c: Drive page actions ordering and default action from the new `pageActions.items` list, including the reorderable assistant action
- ddba803: Add a page reference chip when opening the Assistant from the page action, so the assistant is informed about the page the reader is currently on.
- f4469b2: Support OpenAPI one-operation-per-page mode
- 6c967c9: Add a client-side search field to table blocks, with per-column filters for select and checkbox columns
- 882dfcd: Tidy prompt and add e2e test
- Updated dependencies [f4469b2]
- Updated dependencies [88b94b5]
- @gitbook/react-openapi@1.5.14
## 0.27.0
### Minor Changes
+6
View File
@@ -1113,6 +1113,11 @@ const testCases: TestsCase[] = [
url: 'blocks/mermaid',
run: waitForCookiesDialog,
},
{
name: 'Prompt',
url: 'blocks/prompt',
run: waitForCookiesDialog,
},
],
},
{
@@ -1361,6 +1366,7 @@ const testCases: TestsCase[] = [
pageActions: {
markdown: false,
externalAI: false,
items: [],
},
}),
run: waitForCookiesDialog,
+6
View File
@@ -13,6 +13,7 @@ import {
CustomizationIconsStyle,
CustomizationLinksStyle,
CustomizationLocale,
CustomizationPageActionType,
CustomizationSearchStyle,
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
@@ -414,6 +415,11 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
externalAI: true,
markdown: true,
mcp: true,
items: [
CustomizationPageActionType.Markdown,
CustomizationPageActionType.ExternalAi,
CustomizationPageActionType.Mcp,
],
},
trademark: {
enabled: true,
+1
View File
@@ -48,6 +48,7 @@ const nextConfig = {
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL,
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
GITBOOK_INTEGRATIONS_CONTENT_HOST: process.env.GITBOOK_INTEGRATIONS_CONTENT_HOST,
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.27.0",
"version": "0.27.1",
"private": true,
"dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
+10
View File
@@ -18,6 +18,16 @@ export default defineConfig({
use: {
...devices['Desktop Chrome'],
channel: 'chrome',
launchOptions: {
args: [
// Disable subpixel (LCD) text so glyphs always render with
// grayscale antialiasing — removes the red/blue edge fringing
// that varies between macOS (local) and Linux (CI) runs.
'--disable-lcd-text',
// Disable font hinting so glyph rasterization is platform-independent.
'--font-render-hinting=none',
],
},
},
},
],
@@ -1,4 +1,4 @@
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { CustomizationPageActionType, SiteInsightsDisplayContext } from '@gitbook/api';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { getExposableError, throwIfDataError } from '@/lib/data';
@@ -20,7 +20,14 @@ export async function handleMcpRequest(
const { context } = await getDynamicSiteContext(params);
const { dataFetcher, linker, site } = context;
if (!context.customization.pageActions.mcp) {
// Use the configured `items` list when the API provides it, and fall back to the deprecated
// `mcp` flag otherwise (legacy mode), since this endpoint is called directly and cannot rely on
// any page-rendering fallback.
const { pageActions } = context.customization;
const isMcpEnabled = pageActions.items
? pageActions.items.includes(CustomizationPageActionType.Mcp)
: pageActions.mcp;
if (!isMcpEnabled) {
return new Response('Not Found', { status: 404 });
}
@@ -0,0 +1,23 @@
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { StructurePreview } from '@/components/StructurePreview';
import { GITBOOK_APP_URL } from '@/lib/env';
import type { Metadata } from 'next';
import { getStructurePreviewSnapshot } from '../snapshot';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default async function Page(props: PageProps) {
const { context } = await getDynamicSiteContext(await props.params);
return (
<StructurePreview
initialSnapshot={getStructurePreviewSnapshot(context)}
GITBOOK_APP_URL={GITBOOK_APP_URL}
/>
);
}
@@ -0,0 +1,42 @@
import type React from 'react';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { CustomizationRootLayout } from '@/components/RootLayout';
import { SiteLayoutClientContexts } from '@/components/SiteLayout/SiteLayoutClientContexts';
import { getThemeFromMiddleware } from '@/lib/middleware';
interface SiteDynamicLayoutProps {
params: Promise<RouteLayoutParams>;
}
export default async function RootLayout({
children,
...props
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
const { context } = await getDynamicSiteContext(await props.params);
const forcedTheme = await getThemeFromMiddleware();
return (
<CustomizationRootLayout
htmlClassName="sheet-open:gutter-stable overflow-hidden site-background"
bodyClassName="site-background"
forcedTheme={forcedTheme}
context={context}
>
<SiteLayoutClientContexts
contextId={context.contextId}
forcedTheme={
forcedTheme ??
(context.customization.themes.toggeable
? undefined
: context.customization.themes.default)
}
defaultTheme={context.customization.themes.default}
themeStorageKey={`gitbook-theme-structure:${context.site.id}`}
externalLinksTarget={context.customization.externalLinks.target}
proxyOrigin={context.site.proxy?.origin}
>
{children}
</SiteLayoutClientContexts>
</CustomizationRootLayout>
);
}
@@ -0,0 +1,184 @@
import type {
CustomizationContentLink,
CustomizationHeaderItem,
SiteSection,
SiteSectionGroup,
SiteSpace,
} from '@gitbook/api';
import assertNever from 'assert-never';
import type {
ClientSiteSection,
ClientSiteSectionGroup,
ClientSiteSections,
} from '@/components/SiteSections';
import { categorizeVariants } from '@/components/SpaceLayout/categorizeVariants';
import type { StructurePreviewSnapshot } from '@/components/StructurePreview';
import type { PreviewContentLink, PreviewHeaderLink } from '@/components/StructurePreview/types';
import type { GitBookSiteContext, SiteSections } from '@/lib/context';
import { getLocalizedDescription, getLocalizedTitle } from '@/lib/sites';
export function getStructurePreviewSnapshot(context: GitBookSiteContext): StructurePreviewSnapshot {
const variants = categorizeVariants(context);
const sections = context.visibleSections ?? context.sections;
return {
site: {
title: context.site.title,
},
locale: context.locale,
customization: encodePreviewCustomization(context),
siteSpace: encodePreviewSiteSpace(context.siteSpace, context),
variants: {
generic: variants.generic.map((siteSpace) =>
encodePreviewDropdownSpace(siteSpace, context)
),
translations: variants.translations.map((siteSpace) =>
encodePreviewDropdownSpace(siteSpace, context)
),
},
sections: sections ? encodePreviewSiteSections(context, sections) : null,
icons: {
large: {
light: context.linker.toPathInSpace('~gitbook/icon?size=large&theme=light'),
dark: context.linker.toPathInSpace('~gitbook/icon?size=large&theme=dark'),
},
},
};
}
function encodePreviewCustomization(
context: GitBookSiteContext
): StructurePreviewSnapshot['customization'] {
const { customization, locale } = context;
return {
styling: {
search: customization.styling.search,
},
favicon:
'emoji' in customization.favicon && customization.favicon.emoji
? { emoji: customization.favicon.emoji }
: {},
header: {
preset: customization.header.preset,
logo: customization.header.logo
? {
light: customization.header.logo.light,
dark: customization.header.logo.dark,
}
: undefined,
links: customization.header.links.map((link) => encodePreviewHeaderLink(link, locale)),
},
ai: {
mode: customization.ai.mode,
},
trademark: {
enabled: customization.trademark.enabled,
},
socialAccounts: customization.socialAccounts
.filter((account) => account.display.header === true)
.map((account) => ({
platform: account.platform,
handle: account.handle,
})),
};
}
function encodePreviewHeaderLink(
link: CustomizationHeaderItem,
locale: GitBookSiteContext['locale']
): PreviewHeaderLink {
return {
title: getLocalizedTitle(link, locale),
style: link.style,
hasTarget: Boolean(link.to),
links: link.links.map((subLink) => encodePreviewContentLink(subLink, locale)),
};
}
function encodePreviewContentLink(
link: CustomizationContentLink,
locale: GitBookSiteContext['locale']
): PreviewContentLink {
return {
title: getLocalizedTitle(link, locale),
hasTarget: Boolean(link.to),
};
}
function encodePreviewSiteSpace(
siteSpace: SiteSpace,
context: GitBookSiteContext
): StructurePreviewSnapshot['siteSpace'] {
return {
id: siteSpace.id,
title: getLocalizedTitle(siteSpace, context.locale),
path: siteSpace.path,
};
}
function encodePreviewDropdownSpace(
siteSpace: SiteSpace,
context: GitBookSiteContext
): StructurePreviewSnapshot['variants']['generic'][number] {
return {
id: siteSpace.id,
title: getLocalizedTitle(siteSpace, context.locale),
isActive: siteSpace.id === context.siteSpace.id,
};
}
export function encodePreviewSiteSections(
context: Pick<GitBookSiteContext, 'locale'>,
sections: SiteSections
): ClientSiteSections {
return {
list: sections.list.flatMap((item) => encodePreviewSectionItem(context, item)),
current: encodePreviewSection(context, sections.current),
};
}
function encodePreviewSectionItem(
context: Pick<GitBookSiteContext, 'locale'>,
item: SiteSection | SiteSectionGroup
): (ClientSiteSection | ClientSiteSectionGroup)[] {
switch (item.object) {
case 'site-section':
return [encodePreviewSection(context, item)];
case 'site-section-group': {
const children = item.children.flatMap((child) =>
encodePreviewSectionItem(context, child)
);
if (children.length === 0) {
return [];
}
return [
{
id: item.id,
title: getLocalizedTitle(item, context.locale),
icon: item.icon,
object: item.object,
children,
},
];
}
default:
assertNever(item);
}
}
function encodePreviewSection(
context: Pick<GitBookSiteContext, 'locale'>,
section: SiteSection
): ClientSiteSection {
return {
id: section.id,
title: getLocalizedTitle(section, context.locale),
description: getLocalizedDescription(section, context.locale),
icon: section.icon,
object: section.object,
url: '#',
};
}
@@ -0,0 +1,24 @@
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { serveAskMarkdown } from '@/routes/markdownAsk';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
/**
* Serve an AI answer as markdown for a page, steered by the end goal ("goal")
* the calling agent provided via the `?goal=` search parameter.
*
* The goal is encoded as a path segment (rather than read from the query) because
* this route is statically rendered, mirroring how the question itself is handled.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams & { question: string; goal: string }> }
) {
const { question: encodedQuestion, goal: encodedGoal } = await params;
const { context } = await getStaticSiteContext(await params);
const question = decodeURIComponent(encodedQuestion);
const goal = decodeURIComponent(encodedGoal);
return serveAskMarkdown(context, question, { goal });
}
+2
View File
@@ -10,6 +10,7 @@ import {
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_INTEGRATIONS_CONTENT_HOST,
GITBOOK_INTEGRATIONS_HOST,
GITBOOK_OAUTH_SERVER_URL,
GITBOOK_SECRET,
@@ -32,6 +33,7 @@ export async function GET(_req: NextRequest) {
GITBOOK_ICONS_URL,
GITBOOK_USER_AGENT,
GITBOOK_INTEGRATIONS_HOST,
GITBOOK_INTEGRATIONS_CONTENT_HOST,
GITBOOK_DISABLE_TRACKING,
// Secret envs
@@ -19,7 +19,13 @@ export type PageReference = BaseAIChatReference & {
href?: string;
};
export type AIChatReference = CodeBlockReference | PageReference;
export type TextReference = BaseAIChatReference & {
type: 'text';
/** The selected text content. */
content: string;
};
export type AIChatReference = CodeBlockReference | PageReference | TextReference;
/**
* Serialize the staged references into a preamble prepended to the user's message,
@@ -42,6 +48,11 @@ export function serializeReferences(refs: AIChatReference[]): string {
sections.push(serializeCodeBlockReferences(codeRefs));
}
const textRefs = refs.filter((ref): ref is TextReference => ref.type === 'text');
if (textRefs.length > 0) {
sections.push(serializeTextReferences(textRefs));
}
if (sections.length === 0) {
return '';
}
@@ -66,6 +77,19 @@ function serializeCodeBlockReferences(refs: CodeBlockReference[]): string {
return `The user is referring to the following code block${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`;
}
function serializeTextReferences(refs: TextReference[]): string {
const plural = refs.length > 1;
const blocks = refs.map((ref) => quoteText(ref.content)).join('\n\n');
return `The user is referring to the following excerpt${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`;
}
function quoteText(content: string): string {
return content
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
}
function buildCodeBlockFence(ref: CodeBlockReference): string {
const { label, content, syntax } = ref;
let max = 2;
@@ -31,9 +31,11 @@ import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
import { AIChatControl } from './AIChatControl';
import { AIChatControlButton } from './AIChatControlButton';
import { AIChatExpandButton } from './AIChatExpandButton';
import { AIChatIcon } from './AIChatIcon';
import { AIChatInput } from './AIChatInput';
import { AIChatMessages } from './AIChatMessages';
import { AIChatResizeHandle } from './AIChatResizeHandle';
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
export function AIChat() {
@@ -85,9 +87,10 @@ export function AIChat() {
withOverlay={true}
data-ai-chat
className={tcls(
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:w-(--ai-chat-width)'
)}
>
<AIChatResizeHandle />
<EmbeddableFrame className="relative w-full shrink-0 border-tint-subtle border-l to-tint-base">
<EmbeddableFrameMain data-testid="ai-chat" aria-busy={chat.loading}>
<EmbeddableFrameHeader className="not-embed:px-4">
@@ -100,6 +103,7 @@ export function AIChat() {
</EmbeddableFrameHeaderMain>
<EmbeddableFrameButtons>
<AIChatControlButton />
<AIChatExpandButton />
<Button
onClick={() => chatController.close()}
iconOnly
@@ -1,6 +1,9 @@
'use client';
import type { ReactNode } from 'react';
import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import type { Assistant } from '../AI';
import { useIsMobile } from '../hooks/useIsMobile';
import { Button } from '../primitives';
@@ -8,6 +11,49 @@ import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
const MOBILE_BREAKPOINT = 688; // 43rem, equal to Tailwind's @max-2xl container breakpoint
/**
* Button visual for an AI assistant in the header.
*/
export function AIChatButtonView(props: {
icon: ReactNode;
label: string;
onClick?: () => void;
showLabel?: boolean;
withShortcut?: boolean;
inert?: boolean;
}) {
const { icon, label, onClick, showLabel = true, withShortcut = true, inert = false } = props;
const language = useLanguage();
const isMobile = useIsMobile(MOBILE_BREAKPOINT, '[data-gb-header-content]');
return (
<Button
icon={icon}
data-testid="ai-chat-button"
iconOnly={!showLabel || isMobile}
size="medium"
variant="header"
label={
<div className="flex items-center gap-2">
{t(language, 'ai_chat_ask', label)}
{withShortcut ? (
<KeyboardShortcut
keys={['mod', 'i']}
className="border-tint-11 text-tint-1"
/>
) : null}
</div>
}
aria-label={tString(language, 'ai_chat_ask', label)}
onClick={inert ? undefined : onClick}
tabIndex={inert ? -1 : undefined}
className={tcls(inert ? 'pointer-events-none select-none' : null)}
>
{showLabel ? t(language, 'ask') : null}
</Button>
);
}
/**
* Button to open/close the AI chat.
*/
@@ -17,31 +63,14 @@ export function AIChatButton(props: {
withShortcut?: boolean;
}) {
const { assistant, showLabel = true, withShortcut = true } = props;
const language = useLanguage();
const isMobile = useIsMobile(MOBILE_BREAKPOINT, '[data-gb-header-content]');
return (
<Button
<AIChatButtonView
icon={assistant.icon}
data-testid="ai-chat-button"
iconOnly={!showLabel || isMobile}
size="medium"
variant="header"
label={
<div className="flex items-center gap-2">
{t(language, 'ai_chat_ask', assistant.label)}
{withShortcut ? (
<KeyboardShortcut
keys={['mod', 'i']}
className="border-tint-11 text-tint-1"
/>
) : null}
</div>
}
aria-label={tString(language, 'ai_chat_ask', assistant.label)}
label={assistant.label}
onClick={() => assistant.open()}
>
{showLabel ? t(language, 'ask') : null}
</Button>
showLabel={showLabel}
withShortcut={withShortcut}
/>
);
}
@@ -0,0 +1,35 @@
'use client';
import { tString, useLanguage } from '@/intl/client';
import { Icon } from '@gitbook/icons';
import { Button } from '../primitives';
import { useAIChatWidthStore, useIsAIChatMaxWidth } from './useAIChatWidthStore';
export function AIChatExpandButton() {
const language = useLanguage();
const toggleWidth = useAIChatWidthStore((state) => state.toggleWidth);
const isMaxWidth = useIsAIChatMaxWidth();
return (
<Button
onClick={toggleWidth}
iconOnly
icon={
<Icon
icon={
isMaxWidth
? 'arrow-down-left-and-arrow-up-right-to-center'
: 'arrow-up-right-and-arrow-down-left-from-center'
}
className="scale-90"
/>
}
label={tString(
language,
isMaxWidth ? 'ai_chat_collapse_panel' : 'ai_chat_expand_panel'
)}
variant="blank"
className="max-lg:hidden"
/>
);
}
@@ -51,6 +51,9 @@ export function AIChatInput(props: {
},
{
enableOnFormTags: true,
// Match the logical character so Dvorak ⌘-C (physical "I" key) copies
// instead of focusing the Assistant input. RND-11340.
ignoreEventWhen: (e) => e.key.toLowerCase() !== 'i',
}
);
@@ -33,7 +33,7 @@ export function AIChatReferenceChips(props: {
ref.type === 'code-block' && 'font-mono'
)}
>
{ref.label}
{ref.type === 'text' ? ref.content : ref.label}
</span>
</>
);
@@ -55,6 +55,10 @@ export function AIChatReferenceChips(props: {
>
{content}
</Link>
) : ref.type === 'text' ? (
// A text selection has no persistent DOM anchor to navigate to, so the
// excerpt is shown as plain (non-interactive) content.
<span className={triggerClassName}>{content}</span>
) : (
<button
type="button"
@@ -94,6 +98,8 @@ function getReferenceIcon(ref: AIChatReference): IconName {
return 'code';
case 'page':
return 'memo';
case 'text':
return 'quote-left';
default:
assertNever(ref);
}
@@ -0,0 +1,83 @@
'use client';
import { tcls } from '@/lib/tailwind';
import React from 'react';
import { useAIChatWidthStore } from './useAIChatWidthStore';
function setResizing(active: boolean) {
document.documentElement.dataset.aiChatResizing = String(active);
}
export function AIChatResizeHandle() {
const setWidth = useAIChatWidthStore((state) => state.setWidth);
const frameRef = React.useRef<number | null>(null);
const widthRef = React.useRef(0);
React.useEffect(() => {
const onResize = () => useAIChatWidthStore.getState().syncWidth();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
}
setResizing(false);
};
}, []);
const stopResizing = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
setResizing(false);
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
widthRef.current = useAIChatWidthStore.getState().width;
setResizing(true);
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
return;
}
// Panel is right-anchored, so its width is the distance from the cursor to the right edge.
widthRef.current = window.innerWidth - event.clientX;
if (frameRef.current === null) {
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
widthRef.current = setWidth(widthRef.current);
});
}
};
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
return;
}
setWidth(widthRef.current);
stopResizing(event);
};
return (
<div
aria-hidden="true"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={stopResizing}
className={tcls(
'group -translate-x-1/2 absolute inset-y-0 left-0 z-10 hidden w-3 cursor-col-resize touch-none lg:flex',
'items-stretch justify-center'
)}
>
<span className="h-full w-px rounded-full bg-transparent transition-all duration-150 ease-out group-hover:w-0.5 group-hover:bg-primary-solid/40 group-active:w-0.5 group-active:bg-primary-solid" />
</div>
);
}
@@ -0,0 +1,129 @@
'use client';
import { CustomizationAIMode } from '@gitbook/api';
import fnv1a from '@sindresorhus/fnv1a';
import { AnimatePresence, motion } from 'motion/react';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { useAIChatController, useAIConfig } from '@/components/AI';
import { useIsMobile } from '@/components/hooks/useIsMobile';
import { useIsMounted } from '@/components/hooks/useIsMounted';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { AIChatIcon } from '../AIChatIcon';
import { useStableTextSelection } from './useStableTextSelection';
/** Gap between the selection and the button. */
const GAP = 8;
/** Minimum distance to the viewport edges. */
const MARGIN = 8;
/**
* Floating "Ask" button anchored above a text selection. Clicking it stages the selection as a
* reference and opens the AI chat. Only rendered in Assistant mode, on non-touch devices.
*/
export function AskAITextSelection() {
const config = useAIConfig();
const language = useLanguage();
const chatController = useAIChatController();
const isMobile = useIsMobile();
const isMounted = useIsMounted();
const enabled = config.aiMode === CustomizationAIMode.Assistant && !isMobile;
const toolbarRef = React.useRef<HTMLDivElement>(null);
const { selection, clear } = useStableTextSelection({
rootSelector: '[data-content-ref-root]',
enabled,
ignoreRef: toolbarRef,
});
const [coords, setCoords] = React.useState<{ top: number; left: number } | null>(null);
// Position once the button has been measured, so it can be centered and clamped to the viewport.
React.useLayoutEffect(() => {
if (!selection) {
return;
}
const el = toolbarRef.current;
if (!el) {
return;
}
const width = el.offsetWidth;
const height = el.offsetHeight;
const { anchor } = selection;
let top = anchor.top - GAP - height;
if (top < MARGIN) {
// Not enough room above the selection: drop below it.
top = anchor.bottom + GAP;
}
top = Math.min(top, window.innerHeight - height - MARGIN);
const left = Math.min(
Math.max(anchor.centerX - width / 2, MARGIN),
window.innerWidth - width - MARGIN
);
setCoords({ top, left });
}, [selection]);
const onClick = () => {
if (!selection) {
return;
}
const content = selection.text;
if (!content.trim()) {
return;
}
chatController.addReference({
type: 'text',
id: `text-${fnv1a(content, { size: 32 })}`,
content,
});
chatController.open();
chatController.focus();
clear();
};
if (!enabled || !isMounted) {
return null;
}
return createPortal(
<AnimatePresence>
{selection ? (
<motion.div
ref={toolbarRef}
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: coords ? 1 : 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={{ duration: 0.12, ease: 'easeOut' }}
style={{
position: 'fixed',
top: coords?.top ?? 0,
left: coords?.left ?? 0,
zIndex: 40,
}}
// Keep the selection alive: prevent the button from stealing focus on click.
onMouseDown={(event) => event.preventDefault()}
>
<Button
size="small"
variant="primary"
icon={<AIChatIcon state="default" trademark={config.trademark} />}
onClick={onClick}
className="shadow-sm"
>
{t(language, 'ask')}
</Button>
</motion.div>
) : null}
</AnimatePresence>,
document.body
);
}
@@ -0,0 +1 @@
export * from './AskAITextSelection';
@@ -0,0 +1,202 @@
'use client';
import * as React from 'react';
type SelectionAnchor = {
top: number;
bottom: number;
centerX: number;
};
export type StableTextSelection = {
anchor: SelectionAnchor;
text: string;
};
type Options = {
/** Selector of the content root; both selection endpoints must be inside it. */
rootSelector: string;
enabled: boolean;
/** Pointer events inside this element are ignored, so clicking the UI keeps the selection. */
ignoreRef: React.RefObject<HTMLElement | null>;
};
const KEYBOARD_DEBOUNCE_MS = 250;
const SCROLL_SETTLE_MS = 200;
// Coalesces the rapid down/up bursts of a double/triple-click into a single, non-flashing show.
const POINTER_SETTLE_MS = 120;
/**
* Track a stable text selection within `rootSelector`, debounced so the floating UI it powers
* doesn't flicker mid-gesture. The selection is dropped on collapse, window blur, scroll, and
* when a new gesture starts.
*/
export function useStableTextSelection(options: Options): {
selection: StableTextSelection | null;
clear: () => void;
} {
const { rootSelector, enabled, ignoreRef } = options;
const [selection, setSelection] = React.useState<StableTextSelection | null>(null);
const clear = React.useCallback(() => {
window.getSelection()?.removeAllRanges();
setSelection(null);
}, []);
React.useEffect(() => {
if (!enabled) {
setSelection(null);
return;
}
let pointerDown = false;
let keyboardTimer: ReturnType<typeof setTimeout> | null = null;
let scrollTimer: ReturnType<typeof setTimeout> | null = null;
let finalizeTimer: ReturnType<typeof setTimeout> | null = null;
const clearKeyboardTimer = () => {
if (keyboardTimer) {
clearTimeout(keyboardTimer);
keyboardTimer = null;
}
};
const clearFinalizeTimer = () => {
if (finalizeTimer) {
clearTimeout(finalizeTimer);
finalizeTimer = null;
}
};
const finalize = () => {
// A new gesture started; it will reschedule its own finalize.
if (pointerDown) {
return;
}
setSelection(readStableSelection(rootSelector));
};
const schedulePointerFinalize = () => {
clearKeyboardTimer();
clearFinalizeTimer();
finalizeTimer = setTimeout(() => {
finalizeTimer = null;
finalize();
}, POINTER_SETTLE_MS);
};
const isIgnored = (target: EventTarget | null) =>
target instanceof Node && !!ignoreRef.current?.contains(target);
const onSelectionChange = () => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0 || !sel.toString().trim()) {
clearKeyboardTimer();
setSelection(null);
return;
}
// Mid-drag, or a pointer finalize is already queued: let the gesture settle first.
if (pointerDown || finalizeTimer) {
return;
}
clearKeyboardTimer();
keyboardTimer = setTimeout(finalize, KEYBOARD_DEBOUNCE_MS);
};
const onPointerDown = (event: PointerEvent) => {
if (isIgnored(event.target)) {
return;
}
// New gesture: hide and cancel a pending show so it can't fire mid double-click.
pointerDown = true;
clearFinalizeTimer();
setSelection(null);
};
const onPointerUp = (event: PointerEvent) => {
if (isIgnored(event.target)) {
return;
}
pointerDown = false;
schedulePointerFinalize();
};
// pointercancel replaces pointerup when a gesture is interrupted (OS gesture, scroll handoff).
const onPointerCancel = () => {
pointerDown = false;
schedulePointerFinalize();
};
const onScroll = () => {
setSelection(null);
if (scrollTimer) {
clearTimeout(scrollTimer);
}
scrollTimer = setTimeout(() => {
if (!pointerDown) {
finalize();
}
}, SCROLL_SETTLE_MS);
};
const onWindowBlur = () => {
clearKeyboardTimer();
setSelection(null);
};
document.addEventListener('selectionchange', onSelectionChange);
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('pointerup', onPointerUp, true);
document.addEventListener('pointercancel', onPointerCancel, true);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('blur', onWindowBlur);
return () => {
document.removeEventListener('selectionchange', onSelectionChange);
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('pointerup', onPointerUp, true);
document.removeEventListener('pointercancel', onPointerCancel, true);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('blur', onWindowBlur);
clearKeyboardTimer();
clearFinalizeTimer();
if (scrollTimer) {
clearTimeout(scrollTimer);
}
};
}, [enabled, rootSelector, ignoreRef]);
return { selection, clear };
}
function readStableSelection(rootSelector: string): StableTextSelection | null {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
return null;
}
const text = sel.toString().trim();
if (!text) {
return null;
}
const root = document.querySelector(rootSelector);
if (!root || !root.contains(sel.anchorNode) || !root.contains(sel.focusNode)) {
return null;
}
const anchor = getSelectionAnchor(sel);
return anchor ? { anchor, text } : null;
}
/** Box centered above the whole selection, or null if it has no size or is scrolled out of view. */
function getSelectionAnchor(sel: Selection): SelectionAnchor | null {
const rect = sel.getRangeAt(0).getBoundingClientRect();
if (!rect.width && !rect.height) {
return null;
}
if (rect.bottom < 0 || rect.top > window.innerHeight) {
return null;
}
return { top: rect.top, bottom: rect.bottom, centerX: rect.left + rect.width / 2 };
}
@@ -3,3 +3,4 @@ export * from './AIChatButton';
export * from './AIChatIcon';
export * from './AIResponseFeedback';
export * from './AIChatControlButton';
export * from './AskAITextSelection';
@@ -0,0 +1,78 @@
'use client';
import {
getLocalStorageItem,
removeLocalStorageItem,
setLocalStorageItem,
} from '@/lib/browser/local-storage';
import { create } from 'zustand';
import { type StorageValue, persist } from 'zustand/middleware';
const AI_CHAT_MIN_WIDTH = 384;
const AI_CHAT_MAX_WIDTH = 640;
const MIN_CONTENT_WIDTH = 720;
type AIChatWidthStore = {
width: number;
toggleWidth: () => void;
setWidth: (width: number) => number;
syncWidth: () => void;
};
type PersistedState = Pick<AIChatWidthStore, 'width'>;
export const useAIChatWidthStore = create<AIChatWidthStore>()(
persist(
(set, get) => ({
width: AI_CHAT_MIN_WIDTH,
toggleWidth: () =>
get().setWidth(
get().width >= AI_CHAT_MAX_WIDTH ? AI_CHAT_MIN_WIDTH : AI_CHAT_MAX_WIDTH
),
setWidth: (width) => {
const clamped = clampWidth(width);
if (get().width !== clamped) {
set({ width: clamped });
}
setWidthOnViewport(clamped);
return clamped;
},
syncWidth: () => setWidthOnViewport(get().width),
}),
{
name: '@gitbook/ai-chat-width',
storage: {
getItem: (name) =>
getLocalStorageItem<StorageValue<PersistedState> | null>(name, null),
setItem: (name, value) => setLocalStorageItem(name, value),
removeItem: (name) => removeLocalStorageItem(name),
},
partialize: (state) => ({ width: state.width }),
onRehydrateStorage: () => (state) => state?.syncWidth(),
}
)
);
/**
* Whether the panel is at its maximum width.
*/
export const useIsAIChatMaxWidth = () =>
useAIChatWidthStore((state) => state.width >= AI_CHAT_MAX_WIDTH);
// Hoisted so the synchronous persist rehydrate (during create() above) can call them before this point.
function setWidthOnViewport(width: number) {
if (typeof document !== 'undefined') {
document.documentElement.style.setProperty('--ai-chat-width', `${capToViewport(width)}px`);
}
}
function clampWidth(width: number) {
return Math.min(AI_CHAT_MAX_WIDTH, Math.max(AI_CHAT_MIN_WIDTH, Math.round(width)));
}
// Cap a width so the remaining content keeps a usable minimum at the current viewport.
function capToViewport(width: number) {
return typeof window === 'undefined'
? width
: Math.min(width, Math.max(AI_CHAT_MIN_WIDTH, window.innerWidth - MIN_CONTENT_WIDTH));
}
@@ -42,7 +42,7 @@ export function AnnouncementBanner(props: {
className="theme-bold:bg-header-background pt-4 pb-2"
data-nosnippet=""
>
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-(--ai-chat-width)">
<div className={tcls('relative', CONTAINER_STYLE)}>
<Tag
href={contentRef?.href ?? ''}
@@ -80,8 +80,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'max-w-md',
'text-balance',
'sm:left-auto',
'lg:chat-open:mr-80',
'xl:chat-open:mr-100',
'lg:chat-open:mr-(--ai-chat-width)',
'transition-all',
'motion-reduce:transition-none',
'duration-300',
@@ -28,6 +28,7 @@ import { ListItem } from './ListItem';
import { BlockMath } from './Math';
import { OpenAPIOperation, OpenAPISchemas, OpenAPIWebhook } from './OpenAPI';
import { Paragraph } from './Paragraph';
import { Prompt } from './Prompt';
import { Quote } from './Quote';
import { ReusableContent } from './ReusableContent';
import { Stepper } from './Stepper';
@@ -111,6 +112,8 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
return <Updates {...props} block={block} />;
case 'update':
return <Update {...props} block={block} />;
case 'prompt':
return <Prompt {...props} block={block} />;
case 'if':
// If block should be processed by the API.
return null;
@@ -151,6 +154,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'hint':
case 'tabs':
case 'stepper-step':
case 'prompt':
case 'if':
return <SkeletonParagraph id={id} className={style} />;
case 'expandable':
@@ -33,6 +33,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
const title = block.data.title;
const isSingleLine = theme.lines.length <= 1 && !withWrap;
const id = useId();
const codeId = props.id || id;
@@ -79,7 +80,12 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
) : null}
</div>
<div className="relative flex min-h-0 flex-col">
<div className="absolute top-2 right-2 z-2 flex items-start gap-1.5 font-sans leading-none opacity-0 group-hover/codeblock:opacity-11 has-[button:focus-visible]:opacity-11">
<div
className={tcls(
'absolute right-2 z-2 flex items-center gap-1.5 font-sans leading-none opacity-0 group-hover/codeblock:opacity-11 has-[button:focus-visible]:opacity-11',
isSingleLine ? 'inset-y-0' : 'top-2'
)}
>
{!isPrint ? (
<AskAICodeButton
codeId={codeId}
@@ -92,7 +98,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
</div>
<pre
className={tcls(
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base py-2 text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base print:overflow-visible',
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base py-2.5 text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base print:overflow-visible',
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs depth-subtle:shadow-xs',
title && 'rounded-ss-none!'
)}
@@ -92,6 +92,11 @@ export async function Hint({
'empty:p-0',
'-row-end-1',
'-col-end-1',
// Allow the grid track to shrink below its content's min-content size so wide
// nested blocks (e.g. code blocks, tables) scroll internally instead of
// overflowing the hint. Without this, the `1fr` column keeps its default
// `min-width: auto` and grows past the hint's width.
'min-w-0',
'space-y-3',
'[&_.hint]:border',
'[&_pre]:border',
@@ -151,10 +151,7 @@ function InlineLinkTooltipWrapper(props: {
<InlineLinkTooltip
breadcrumbs={breadcrumbs}
isExternal={isExternal}
isSamePage={isSamePage}
openInNewTabLabel={tString(language, 'open_in_new_tab')}
target={{
href: resolved.href,
text: resolved.text,
subText: resolved.subText,
icon: resolved.icon,
@@ -2,91 +2,69 @@
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { Fragment } from 'react';
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
import { HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
export function InlineLinkTooltip(props: {
isSamePage: boolean;
isExternal: boolean;
breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>;
target: {
href: string;
text: string;
subText?: string;
icon?: React.ReactNode;
};
openInNewTabLabel: string;
children: React.ReactNode;
}) {
const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
const { isExternal, target, breadcrumbs, children } = props;
return (
<HoverCardRoot>
<HoverCardTrigger>{children}</HoverCardTrigger>
<HoverCard className="p-4">
<div className="flex items-start gap-4">
<div className="flex flex-col">
{breadcrumbs && breadcrumbs.length > 0 ? (
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{breadcrumbs.map((crumb, index) => {
const Tag = crumb.href ? StyledLink : 'div';
<div className="flex flex-col">
{breadcrumbs && breadcrumbs.length > 0 ? (
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{breadcrumbs.map((crumb, index) => {
const Tag = crumb.href ? StyledLink : 'div';
return (
<Fragment key={crumb.label}>
{index !== 0 ? (
<Icon
icon="chevron-right"
className="size-3 text-tint-subtle"
/>
return (
<Fragment key={crumb.label}>
{index !== 0 ? (
<Icon
icon="chevron-right"
className="size-3 text-tint-subtle"
/>
) : null}
<Tag
className={tcls(
'flex gap-1',
crumb.href &&
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
)}
href={crumb.href ?? '#'}
>
{crumb.icon ? (
<span className="mt-0.5 text-tint-subtle empty:hidden">
{crumb.icon}
</span>
) : null}
<Tag
className={tcls(
'flex gap-1',
crumb.href &&
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
)}
href={crumb.href ?? '#'}
>
{crumb.icon ? (
<span className="mt-0.5 text-tint-subtle empty:hidden">
{crumb.icon}
</span>
) : null}
{crumb.label}
</Tag>
</Fragment>
);
})}
</div>
) : null}
<div
className={tcls(
'flex gap-2 leading-snug',
isExternal && 'wrap-anywhere text-sm'
)}
>
{target.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">
{target.icon}
</div>
) : null}
<h5 className="font-semibold">{target.text}</h5>
{crumb.label}
</Tag>
</Fragment>
);
})}
</div>
</div>
{!isSamePage && target.href ? (
<Button
className={tcls(
'-mx-2 -my-2 ml-auto',
breadcrumbs?.length === 0 ? 'place-self-center' : null
)}
variant="blank"
href={target.href}
target="_blank"
label={openInNewTabLabel}
size="small"
icon="arrow-up-right-from-square"
iconOnly={true}
/>
) : null}
<div
className={tcls(
'flex gap-2 leading-snug',
isExternal && 'wrap-anywhere text-sm'
)}
>
{target.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">{target.icon}</div>
) : null}
<h5 className="font-semibold">{target.text}</h5>
</div>
</div>
{target.subText ? <p className="mt-1 text-sm text-tint">{target.subText}</p> : null}
</HoverCard>
@@ -1,4 +1,4 @@
import { GITBOOK_INTEGRATIONS_HOST } from '@/lib/env';
import { GITBOOK_INTEGRATIONS_CONTENT_HOST, GITBOOK_INTEGRATIONS_HOST } from '@/lib/env';
import { tcls } from '@/lib/tailwind';
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
@@ -82,7 +82,16 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
renderContext={{
integrationName: block.data.integration,
}}
security={{ firstPartyDomains: [GITBOOK_INTEGRATIONS_HOST] }}
security={{
// Trust both the integrations host and the (cookieless) content host that
// serves rendered WebFrames. `ElementWebframe` gates inbound and outbound
// postMessage on this list, so a WebFrame served from the content host would
// break (no resize/ready/actions) if the content host weren't trusted.
// The hosts are identical until a distinct content origin is configured.
firstPartyDomains: [
...new Set([GITBOOK_INTEGRATIONS_HOST, GITBOOK_INTEGRATIONS_CONTENT_HOST]),
],
}}
initialInput={initialInput}
initialOutput={initialOutput}
render={renderIntegrationUi}
@@ -54,6 +54,7 @@ async function OpenAPIOperationBody(props: BlockProps<AnyOpenAPIOperationsBlock>
data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'],
expandAllModelSections:
data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'],
headless: block.type === 'openapi-operation' ? block.data.headless : undefined,
})}
className="openapi-block"
/>
@@ -56,6 +56,7 @@ async function OpenAPIWebhookBody(props: BlockProps<OpenAPIWebhookBlock>) {
data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'],
expandAllModelSections:
data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'],
headless: block.data.headless,
})}
className="openapi-block"
/>
@@ -26,8 +26,9 @@ export function getOpenAPIContext(args: {
context: GitBookAnyContext | undefined;
expandAllResponses?: boolean;
expandAllModelSections?: boolean;
headless?: boolean;
}): OpenAPIContextInput {
const { props, specUrl, context, expandAllResponses, expandAllModelSections } = args;
const { props, specUrl, context, expandAllResponses, expandAllModelSections, headless } = args;
const { block } = props;
const customizationLocale = context ? getSpaceLocale(context) : DEFAULT_LOCALE;
@@ -96,6 +97,7 @@ export function getOpenAPIContext(args: {
),
expandAllResponses: expandAllResponses || props.context.mode === 'print',
expandAllModelSections: expandAllModelSections || props.context.mode === 'print',
headless,
id: block.meta?.id,
blockKey: block.key,
locale,
@@ -272,7 +272,7 @@ body {
}
.scalar-activate-button {
@apply flex gap-2 items-center;
@apply bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-md straight-corners:rounded-none circular-corners:rounded-full circular-corners:px-3 place-self-start;
@apply bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-lg straight-corners:rounded-none circular-corners:rounded-full circular-corners:px-3 place-self-start;
@apply depth-subtle:shadow-xs hover:depth-subtle:shadow-md focus-visible:depth-subtle:shadow-md active:depth-subtle:shadow-xs;
@apply contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12;
@apply hover:depth-subtle:-translate-y-px focus-visible:depth-subtle:-translate-y-px active:depth-subtle:translate-y-0 transition-all;
@@ -40,7 +40,7 @@
.openapi-deprecated,
.openapi-stability {
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-sm text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
.openapi-stability-alpha {
@@ -78,7 +78,7 @@
}
.openapi-markdown code {
@apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
@apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
.openapi-markdown pre code {
@@ -101,7 +101,17 @@
/* Method Tags */
.openapi-method,
.openapi-statuscode {
@apply rounded straight-corners:rounded-none circular-corners:rounded-md uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
@apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap;
}
.openapi-method-small {}
.openapi-method-medium {
@apply m-0 px-2.5 py-1 h-6 text-[0.813rem];
}
.toclink .openapi-method {
@apply text-[0.625rem] flex items-center justify-center;
}
.openapi-method-get,
@@ -130,7 +140,8 @@
.openapi-method-head,
.openapi-method-options,
.openapi-method-trace {
.openapi-method-trace,
.openapi-method-hook {
@apply bg-tint;
}
@@ -149,13 +160,12 @@
}
.openapi-column-preview {
/* Shift down by ~2rem so the pinned preview clears the sticky "On this page" /
page-actions bar below the header, and leave a 1rem gap at the bottom of the viewport. */
@apply flex flex-col flex-1 xl:max-2xl:pt-8 lg:pt-6 sticky self-start max-h-[calc(100vh-var(--toc-top-offset)-3rem)] top-[calc(var(--toc-top-offset)+2rem)];
@apply flex flex-col flex-1 sticky self-start max-h-[calc(100vh-var(--toc-top-offset))] scroll-mt-4 top-[calc(var(--toc-top-offset)+1.5rem)];
}
.openapi-column-preview-body {
@apply flex flex-col shrink overflow-hidden gap-4 print-mode:static;
/* overflow-clip + clip-margin so panel box-shadows aren't cut while overflow is still clipped */
@apply flex flex-col shrink overflow-clip [overflow-clip-margin:0.75rem] gap-4 print-mode:static;
}
.openapi-column-preview pre {
@@ -278,11 +288,11 @@
}
.openapi-schema-enum-value:first-child {
@apply rounded-l straight-corners:rounded-none circular-corners:rounded-l-md ml-0;
@apply rounded-l-md straight-corners:rounded-none circular-corners:rounded-l-lg ml-0;
}
.openapi-schema-enum-value:last-child {
@apply rounded-r straight-corners:rounded-none circular-corners:rounded-r-md;
@apply rounded-r-md straight-corners:rounded-none circular-corners:rounded-r-lg;
}
/* Schema Description */
@@ -316,7 +326,7 @@
.openapi-schema-pattern code,
.openapi-schema-enum-value code,
.openapi-schema-default code {
@apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
@apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
/* Authentication */
@@ -346,7 +356,7 @@
}
.openapi-securities-url {
@apply ml-0.5 px-0.5 rounded straight-corners:rounded-none circular-corners:rounded-md hover:bg-tint dark:hover:bg-tint-hover transition-colors;
@apply ml-0.5 px-0.5 rounded-md straight-corners:rounded-none circular-corners:rounded-lg hover:bg-tint dark:hover:bg-tint-hover transition-colors;
}
.openapi-securities-body {
@@ -398,7 +408,7 @@
}
.openapi-response-tab-content {
@apply flex items-baseline truncate grow shrink max-w-max basis-full mr-auto;
@apply flex items-baseline gap-2 truncate grow shrink max-w-max basis-full mr-auto;
@apply text-left text-pretty relative leading-tight text-tint select-text;
}
@@ -452,16 +462,12 @@
@apply max-w-full overflow-hidden shrink pl-0.5 py-0.5;
}
.openapi-response-examples-header .openapi-select > button .openapi-statuscode {
@apply h-full;
}
.openapi-codesample-header-content {
@apply flex flex-row items-center justify-between h-fit p-2.5;
}
.openapi-codesample-header-content .openapi-path {
@apply flex items-center font-mono text-[0.813rem] gap-1 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
@apply flex items-center font-mono text-[0.813rem] gap-2 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
}
.openapi-codesample-header-content .openapi-path-title {
@@ -488,17 +494,13 @@
}
.openapi-path-variable {
@apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-none before:content-none! after:!content-none;
@apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-none before:content-none! after:!content-none;
}
.openapi-path-server {
@apply text-tint inline;
}
.openapi-summary .openapi-path .openapi-method {
@apply m-0 items-center flex px-2 py-1 h-6;
}
.openapi-path-title {
@apply flex-1 relative font-normal items-center gap-y-1 flex flex-wrap text-left overflow-x-auto font-mono text-tint-strong/10;
@apply whitespace-nowrap md:whitespace-normal;
@@ -534,7 +536,7 @@
.openapi-panel,
.openapi-codesample,
.openapi-response-examples {
@apply border shrink min-h-32 overflow-hidden rounded-lg straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base border-tint-subtle depth-subtle:shadow-sm;
@apply border shrink min-h-32 overflow-hidden rounded-corners:rounded-xl circular-corners:rounded-2xl straight-corners:rounded-xs bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base border-tint-subtle depth-subtle:shadow-sm;
}
.openapi-response-examples-panel {
@@ -580,7 +582,7 @@
}
.openapi-response-examples-statuscode-title {
@apply flex items-center;
@apply flex items-center gap-2;
}
.openapi-response-examples-header .openapi-select > button,
@@ -613,7 +615,7 @@ body:has(.openapi-select-popover) {
}
.openapi-select > button {
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded straight-corners:rounded-none circular-corners:rounded-md leading-none;
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded-md straight-corners:rounded-none circular-corners:rounded-lg leading-none;
@apply hover:bg-tint dark:hover:bg-tint-hover transition-all;
}
@@ -646,7 +648,7 @@ body:has(.openapi-select-popover) {
}
.openapi-select-popover {
@apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md straight-corners:rounded-none circular-corners:rounded-xl;
@apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
@apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1;
}
@@ -659,7 +661,7 @@ body:has(.openapi-select-popover) {
}
.openapi-select-item {
@apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded straight-corners:rounded-none circular-corners:rounded-md !outline-none;
@apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded-md straight-corners:rounded-none circular-corners:rounded-lg !outline-none;
@apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
}
@@ -693,11 +695,12 @@ body:has(.openapi-select-popover) {
/* Section Components */
.openapi-section {
@apply flex flex-col overflow-hidden;
/* overflow-clip + clip-margin so the centered AND/OR separator badge isn't cut at the outermost alternatives level */
@apply flex flex-col overflow-clip [overflow-clip-margin:0.75rem];
}
.openapi-section-body {
@apply flex flex-col shrink overflow-hidden;
@apply flex flex-col shrink overflow-clip [overflow-clip-margin:0.75rem];
}
.openapi-section-header {
@@ -755,7 +758,7 @@ body:has(.openapi-select-popover) {
}
.openapi-tabs-tab {
@apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded straight-corners:rounded-none circular-corners:rounded-md;
@apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded-md straight-corners:rounded-none circular-corners:rounded-lg;
}
.openapi-tabs-tab[aria-selected="true"] {
@@ -769,7 +772,7 @@ body:has(.openapi-select-popover) {
/* Disclosure group */
.openapi-disclosure-group {
@apply border-tint-subtle transition-all border-b border-x overflow-auto last:rounded-b-md straight-corners:last:rounded-none circular-corners:last:rounded-b-xl first:rounded-t-md straight-corners:first:rounded-none circular-corners:first:rounded-t-xl first:border-t relative;
@apply border-tint-subtle transition-all border-b border-x overflow-auto rounded-corners:last:rounded-b-xl circular-corners:last:rounded-b-2xl straight-corners:last:rounded-b-xs rounded-corners:first:rounded-t-xl circular-corners:first:rounded-t-2xl straight-corners:first:rounded-t-xs first:border-t relative;
}
.openapi-disclosure-group:has(.openapi-disclosure-group-trigger:hover) {
@@ -822,7 +825,7 @@ body:has(.openapi-select-popover) {
/* Disclosure */
.openapi-schemas-disclosure {
@apply border-t border-x last:border-b border-tint-subtle !ring-0 first:!rounded-t-xl last:!rounded-b-xl !rounded-none;
@apply border-t border-x last:border-b border-tint-subtle !ring-0 rounded-corners:first:!rounded-t-xl rounded-corners:last:!rounded-b-xl circular-corners:first:!rounded-t-2xl circular-corners:last:!rounded-b-2xl straight-corners:first:!rounded-t-xs straight-corners:last:!rounded-b-xs !rounded-none;
}
.openapi-schemas-disclosure > .openapi-disclosure-trigger {
@@ -867,7 +870,7 @@ body:has(.openapi-select-popover) {
.openapi-schema-alternatives .openapi-disclosure,
.openapi-schemas-disclosure .openapi-schema.openapi-disclosure
) {
@apply rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
@apply rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
}
.openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure {
@@ -890,7 +893,7 @@ body:has(.openapi-select-popover) {
}
.openapi-disclosure-trigger-label {
@apply absolute right-3 font-sans! mr-px px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs;
@apply absolute right-3 font-sans! mr-px px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded-md straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs;
}
.openapi-disclosure-trigger-label span {
@@ -953,7 +956,7 @@ body:has(.openapi-select-popover) {
}
.openapi-tooltip {
@apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-md straight-corners:rounded-none circular-corners:rounded-lg font-medium px-1.5 py-0.5 shadow-sm text-[13px];
@apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none font-medium px-1.5 py-0.5 shadow-sm text-[13px];
}
.openapi-tooltip svg {
@@ -1017,7 +1020,7 @@ body:has(.openapi-select-popover) {
}
.openapi-path-copy-button {
@apply p-1 flex rounded-md straight-corners:rounded-none;
@apply p-1 flex rounded-lg straight-corners:rounded-none;
@apply hover:bg-tint dark:hover:bg-tint-hover;
}
@@ -1034,7 +1037,7 @@ body:has(.openapi-select-popover) {
}
.openapi-required-scopes {
@apply border text-base rounded-md straight-corners:rounded-none circular-corners:rounded-md font-medium mx-0;
@apply border text-base rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none font-medium mx-0;
}
.openapi-required-scopes .openapi-required-scopes-header {
@@ -0,0 +1,65 @@
import { tcls } from '@/lib/tailwind';
import {
CustomizationPageActionType,
type DocumentBlockPrompt,
type SiteCustomizationSettings,
} from '@gitbook/api';
import { validateIconName } from '@gitbook/icons/icons';
import type { BlockProps } from '../Block';
import { getPlainCodeBlock } from '../CodeBlock/highlight';
import { PromptClient } from './PromptClient';
export function Prompt(props: BlockProps<DocumentBlockPrompt>) {
const { block } = props;
const contentIcon =
block.data.icon && validateIconName(block.data.icon) ? block.data.icon : null;
return (
<div
className={tcls(
'relative flex w-full flex-col overflow-hidden',
'border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base',
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs',
'depth-subtle:shadow-xs'
)}
>
<PromptClient
contentIcon={contentIcon}
description={block.data.description}
prompt={getPromptText(block)}
openInAIProviders={getOpenInAIProviders(props)}
/>
</div>
);
}
function getOpenInAIProviders(props: BlockProps<DocumentBlockPrompt>): boolean {
const { block, context } = props;
const { openInAIProviders } = block.data;
if (openInAIProviders !== undefined) {
return openInAIProviders;
}
const contentContext = context.contentContext;
if (contentContext && 'customization' in contentContext) {
const { pageActions } = contentContext.customization;
return isExternalAIPageActionEnabled(pageActions);
}
return false;
}
function isExternalAIPageActionEnabled(
pageActions: SiteCustomizationSettings['pageActions']
): boolean {
// Use the configured `items` list when the API provides it, and fall back to the deprecated
// `externalAI` flag otherwise (legacy mode).
return pageActions.items
? pageActions.items.includes(CustomizationPageActionType.ExternalAi)
: pageActions.externalAI;
}
function getPromptText(block: DocumentBlockPrompt): string {
return (block.nodes ?? []).map((node) => getPlainCodeBlock(node)).join('\n');
}
@@ -0,0 +1,212 @@
'use client';
import {
Button,
ButtonGroup,
DropdownMenu,
DropdownMenuItem,
ToggleChevron,
} from '@/components/primitives';
import { getURLForLLM } from '@/components/utils';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import React from 'react';
const OPEN_IN_AI_PROVIDERS = ['claude', 'chatgpt', 'cursor'] as const;
type AIProviders = (typeof OPEN_IN_AI_PROVIDERS)[number];
export function PromptClient(props: {
contentIcon: IconName | null;
description: string;
prompt: string;
openInAIProviders: boolean;
}) {
const { contentIcon, description, prompt, openInAIProviders } = props;
const language = useLanguage();
const promptId = React.useId();
const [open, setOpen] = React.useState(false);
const [headerHasFocus, setHeaderHasFocus] = React.useState(false);
return (
<>
<div className="group/prompt-header relative flex min-h-9 flex-row items-center justify-between gap-4 px-3 py-2">
<button
type="button"
aria-controls={promptId}
aria-expanded={open}
aria-label={tString(language, 'view')}
className={tcls(
'absolute inset-0 z-10 cursor-pointer outline-hidden',
'focus-visible:ring-2 focus-visible:ring-primary-hover'
)}
disabled={!prompt}
onBlur={() => setHeaderHasFocus(false)}
onClick={() => setOpen((prev) => !prev)}
onFocus={() => setHeaderHasFocus(true)}
/>
<div className="pointer-events-none relative z-0 flex min-w-0 flex-row items-center gap-2 text-tint-strong">
<PromptDisclosureIcon
contentIcon={contentIcon}
headerHasFocus={headerHasFocus}
open={open}
/>
<span className="min-w-0 truncate">{description}</span>
</div>
<PromptActions prompt={prompt} openInAIProviders={openInAIProviders} />
</div>
{open ? (
<div id={promptId} className="border-tint-subtle border-t bg-tint-base">
<pre className="overflow-auto p-4 text-sm text-tint-strong">
<code className="language-markdown whitespace-pre-wrap font-mono">
{prompt}
</code>
</pre>
</div>
) : null}
</>
);
}
function PromptDisclosureIcon(props: {
contentIcon: IconName | null;
headerHasFocus: boolean;
open: boolean;
}) {
const { contentIcon, headerHasFocus, open } = props;
return (
<span className="relative flex size-4 shrink-0 items-center justify-center">
{contentIcon ? (
<>
<span
className={tcls(
'flex items-center transition-opacity duration-150 group-hover/prompt-header:opacity-0',
headerHasFocus && 'opacity-0'
)}
>
<Icon icon={contentIcon} className="size-4 shrink-0" />
</span>
<span
className={tcls(
'absolute inset-0 flex items-center justify-center text-tint-subtle opacity-0 transition-opacity duration-150 group-hover/prompt-header:opacity-100',
headerHasFocus && 'opacity-100'
)}
>
<ToggleChevron open={open} orientation="right-to-down" className="size-3" />
</span>
</>
) : (
<ToggleChevron
open={open}
orientation="right-to-down"
className="size-3 text-tint-subtle"
/>
)}
</span>
);
}
function PromptActions(props: { prompt: string; openInAIProviders: boolean }) {
const { prompt, openInAIProviders } = props;
return (
<ButtonGroup className="relative z-20 shrink-0 overflow-visible">
<CopyPromptButton prompt={prompt} />
{openInAIProviders ? <OpenPromptDropdown prompt={prompt} /> : null}
</ButtonGroup>
);
}
// time in milliseconds to show the "Copied" message after copying a prompt
const COPIED_MESSAGE_DURATION = 1000;
function CopyPromptButton(props: { prompt: string }) {
const { prompt } = props;
const language = useLanguage();
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) {
return;
}
const timeout = setTimeout(() => {
setCopied(false);
}, COPIED_MESSAGE_DURATION);
return () => {
clearTimeout(timeout);
};
}, [copied]);
return (
<Button
variant="secondary"
size="xsmall"
icon={copied ? 'check' : 'copy'}
label={copied ? tString(language, 'code_copied') : tString(language, 'prompt_copy')}
className="bg-tint-base"
disabled={!prompt}
onClick={() => {
navigator.clipboard.writeText(prompt);
setCopied(true);
}}
/>
);
}
function OpenPromptDropdown(props: { prompt: string }) {
const { prompt } = props;
const language = useLanguage();
return (
<DropdownMenu
align="end"
className="!min-w-48 max-w-max"
button={
<Button
icon={<ToggleChevron className="size-text-sm" />}
label={tString(language, 'open')}
iconOnly
size="xsmall"
variant="secondary"
className="bg-tint-base"
disabled={!prompt}
/>
}
>
{OPEN_IN_AI_PROVIDERS.map((provider) => {
const definition = getPromptOpenActionDefinition(provider);
return (
<DropdownMenuItem
key={provider}
href={getURLForLLM(provider, prompt)}
target="_blank"
leadingIcon={definition.icon}
>
{tString(language, 'open_in', definition.label)}
</DropdownMenuItem>
);
})}
</DropdownMenu>
);
}
function getPromptOpenActionDefinition(action: AIProviders): { icon: IconName; label: string } {
switch (action) {
case 'cursor':
return {
icon: 'cursor',
label: 'Cursor',
};
case 'claude':
return {
icon: 'claude',
label: 'Claude',
};
case 'chatgpt':
return {
icon: 'chatgpt',
label: 'ChatGPT',
};
}
}
@@ -0,0 +1,2 @@
export * from './Prompt';
export type * from './types';
@@ -0,0 +1,14 @@
import type { DocumentBlockCode } from '@gitbook/api';
export type PromptBlock = {
object: 'block';
type: 'prompt';
key?: string;
data: {
icon?: string;
description?: string;
openInAIProviders?: boolean;
};
nodes?: DocumentBlockCode[];
isVoid?: false;
};
@@ -4,6 +4,7 @@ import { tcls } from '@/lib/tailwind';
import { RecordColumnValue } from './RecordColumnValue';
import type { TableRecordKV, TableViewProps } from './Table';
import { TableSearchRecord } from './TableSearch';
import { getColumnWidth } from './layout';
import { getColumnVerticalAlignment } from './utils';
@@ -14,19 +15,21 @@ export function RecordRow(
fixedColumns: string[];
}
) {
const { view, autoSizedColumns, fixedColumns, block, context } = props;
const { view, record, autoSizedColumns, fixedColumns, block, context } = props;
const stickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true;
const firstVisibleColumn = view.columns[0];
return (
<div
<TableSearchRecord
role="row"
recordId={record[0]}
visibleClassName="flex"
className={tcls(
'group/row flex',
'group/row',
'border-tint-subtle',
'transition-colors',
'hover:bg-tint-hover'
)}
role="row"
>
{view.columns.map((column) => {
const columnWidth = getColumnWidth({
@@ -63,6 +66,6 @@ export function RecordRow(
</div>
);
})}
</div>
</TableSearchRecord>
);
}
@@ -1,4 +1,4 @@
import type { DocumentBlockTable, DocumentTableRecord } from '@gitbook/api';
import type { DocumentBlockTable } from '@gitbook/api';
import assertNever from 'assert-never';
import { tcls } from '@/lib/tailwind';
@@ -6,11 +6,23 @@ import { tcls } from '@/lib/tailwind';
import type { BlockProps } from '../Block';
import { isBlockOffscreen } from '../utils';
import { StickyViewGrid } from './StickyViewGrid';
import { TableSearchEmpty, TableSearchInput, TableSearchProvider } from './TableSearch';
import { ViewCards } from './ViewCards';
import { ViewGrid, ViewGridHeader } from './ViewGrid';
import { getViewGridLayout, hasVisibleHeader } from './layout';
import {
type TableRecordKV,
getTableCheckboxColumns,
getTableRecordSearchData,
getTableSelectColumns,
} from './search';
export type TableRecordKV = [string, DocumentTableRecord];
export type { TableRecordKV };
/**
* Only show the table search once there are enough records that searching is useful.
*/
const MIN_RECORDS_FOR_SEARCH = 7;
export interface TableViewProps<View> extends BlockProps<DocumentBlockTable> {
view: View;
@@ -22,9 +34,40 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
const { block, ancestorBlocks, document, context, style } = props;
const isOffscreen = isBlockOffscreen({ block, ancestorBlocks, document });
const records: TableRecordKV[] = Object.entries(block.data.records).sort((a, b) => {
return a[1].orderIndex.localeCompare(b[1].orderIndex);
});
const records: TableRecordKV[] = Object.entries(block.data.records).sort((a, b) =>
a[1].orderIndex.localeCompare(b[1].orderIndex)
);
const showSearch = context.mode !== 'print' && records.length >= MIN_RECORDS_FOR_SEARCH;
const searchRecords = showSearch
? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
: [];
return (
<TableSearchProvider records={searchRecords}>
<div className={tcls(style, 'flex flex-col gap-3')}>
{showSearch ? (
<TableSearchInput
selectColumns={getTableSelectColumns(block)}
checkboxColumns={getTableCheckboxColumns(block)}
/>
) : null}
<TableView {...props} isOffscreen={isOffscreen} records={records} />
<TableSearchEmpty />
</div>
</TableSearchProvider>
);
}
/**
* Renders the table itself (grid or cards view) for the given records.
*/
function TableView({
isOffscreen,
records,
...props
}: BlockProps<DocumentBlockTable> & { isOffscreen: boolean; records: TableRecordKV[] }) {
const { block, context, style } = props;
switch (block.data.view.type) {
case 'cards':
@@ -0,0 +1,310 @@
'use client';
import { Button, Checkbox, DropdownMenu, DropdownMenuItem, Input } from '@/components/primitives';
import { tString, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import React from 'react';
import type { TableCheckboxColumn, TableSelectColumn } from './search';
import { type SelectedOptions, recordMatches } from './searchMatch';
/**
* Client-side table search.
*
* Site pages are statically rendered and can't read `searchParams`, so filtering happens entirely
* on the client rather than round-tripping through the server. The provider matches every record
* once and exposes the set of visible ids; each row/card just looks itself up by id.
*/
/** Per-record matching data, computed on the server. */
export interface TableSearchRecordData {
/** Record key, matching the `key` passed to `<TableSearchRecord>`. */
id: string;
searchText: string;
selectValues?: Record<string, string[]>;
checkboxValues?: Record<string, boolean>;
}
type TableSearchContextValue = {
query: string;
setQuery: (query: string) => void;
selectedOptions: SelectedOptions;
toggleOption: (column: string, value: string) => void;
/** Checkbox columns whose filter is currently enabled. */
checkedColumns: ReadonlySet<string>;
toggleCheckbox: (column: string) => void;
/**
* Ids of the records matching the active filters, or `null` when no filter is active
* (in which case every record is shown).
*/
visibleIds: ReadonlySet<string> | null;
/** True when there are records but the active filters match none of them. */
isEmpty: boolean;
};
const TableSearchContext = React.createContext<TableSearchContextValue | null>(null);
/**
* Holds the search query and active filters for a single table.
*/
export function TableSearchProvider(props: {
records?: TableSearchRecordData[];
children: React.ReactNode;
}) {
const { records = [] } = props;
const [query, setQuery] = React.useState('');
const [selectedOptions, setSelectedOptions] = React.useState<SelectedOptions>(() => ({}));
const [checkedColumns, setCheckedColumns] = React.useState<ReadonlySet<string>>(
() => new Set()
);
const toggleOption = React.useCallback((column: string, value: string) => {
setSelectedOptions((previous) => {
const values = new Set(previous[column]);
if (values.has(value)) {
values.delete(value);
} else {
values.add(value);
}
const next = { ...previous };
if (values.size === 0) {
delete next[column];
} else {
next[column] = values;
}
return next;
});
}, []);
const toggleCheckbox = React.useCallback((column: string) => {
setCheckedColumns((previous) => {
const next = new Set(previous);
if (next.has(column)) {
next.delete(column);
} else {
next.add(column);
}
return next;
});
}, []);
const hasActiveFilters =
query.trim() !== '' || Object.keys(selectedOptions).length > 0 || checkedColumns.size > 0;
// Match every record once, here, rather than in each row — rows just look themselves up by id.
const visibleIds = React.useMemo(() => {
if (!hasActiveFilters) {
return null;
}
const ids = new Set<string>();
for (const record of records) {
if (
recordMatches(
record.searchText,
record.selectValues,
record.checkboxValues,
query,
selectedOptions,
checkedColumns
)
) {
ids.add(record.id);
}
}
return ids;
}, [records, query, selectedOptions, checkedColumns, hasActiveFilters]);
const isEmpty = visibleIds !== null && records.length > 0 && visibleIds.size === 0;
const value = React.useMemo(
() => ({
query,
setQuery,
selectedOptions,
toggleOption,
checkedColumns,
toggleCheckbox,
visibleIds,
isEmpty,
}),
[query, selectedOptions, toggleOption, checkedColumns, toggleCheckbox, visibleIds, isEmpty]
);
return (
<TableSearchContext.Provider value={value}>{props.children}</TableSearchContext.Provider>
);
}
function useTableSearch(): TableSearchContextValue {
const context = React.useContext(TableSearchContext);
if (!context) {
throw new Error('useTableSearch must be used within a <TableSearchProvider>');
}
return context;
}
/**
* The search form rendered above a table, with a multi-select filter dropdown per select column.
*/
export function TableSearchInput(props: {
selectColumns?: TableSelectColumn[];
checkboxColumns?: TableCheckboxColumn[];
className?: ClassValue;
}) {
const { selectColumns = [], checkboxColumns = [] } = props;
const language = useLanguage();
const { query, setQuery } = useTableSearch();
const hasFilters = selectColumns.length > 0 || checkboxColumns.length > 0;
return (
<Input
label={tString(language, 'search')}
value={query}
onValueChange={setQuery}
leading="magnifying-glass"
clearButton
sizing="small"
keyboardShortcut={false}
className={tcls('w-full', props.className)}
trailing={
hasFilters ? (
// Stop clicks from bubbling to the input container, which would refocus the input.
<div
className="flex items-center gap-1"
onClick={(event) => event.stopPropagation()}
>
{selectColumns.map((column) => (
<SelectFilterDropdown key={column.id} column={column} />
))}
{checkboxColumns.map((column) => (
<CheckboxFilter key={column.id} column={column} />
))}
</div>
) : undefined
}
/>
);
}
/**
* Shown below the table when the active filters match no records.
*/
export function TableSearchEmpty(props: { className?: ClassValue }) {
const language = useLanguage();
const { query, isEmpty } = useTableSearch();
if (!isEmpty) {
return null;
}
const trimmed = query.trim();
return (
<div className={tcls('mx-auto text-center text-sm text-tint', props.className)}>
{trimmed
? tString(language, 'search_no_results_for', trimmed)
: tString(language, 'search_no_results')}
</div>
);
}
/**
* A blank multi-select dropdown button for a single select column. Becomes `active` while any
* of its options are selected.
*/
function SelectFilterDropdown(props: { column: TableSelectColumn }) {
const { column } = props;
const language = useLanguage();
const { selectedOptions, toggleOption } = useTableSearch();
const selectedValues = selectedOptions[column.id];
const activeCount = selectedValues?.size ?? 0;
// Fall back to a generic "Filter" label when the column title is hidden/empty.
const label = column.label.trim() || tString(language, 'search_scope_title');
return (
<DropdownMenu
align="end"
button={
<Button
variant="blank"
size="xsmall"
active={activeCount > 0}
label={activeCount > 1 ? `${label} · ${activeCount}` : label}
trailing={<Icon icon="chevron-down" className="size-3" />}
/>
}
>
{column.options.map((option) => {
const selected = selectedValues?.has(option.value) ?? false;
return (
<DropdownMenuItem
key={option.value}
active={selected}
leadingIcon={selected ? 'check' : undefined}
onSelect={(event) => {
// Keep the menu open so several options can be toggled at once.
event.preventDefault();
toggleOption(column.id, option.value);
}}
>
{option.label || option.value}
</DropdownMenuItem>
);
})}
</DropdownMenu>
);
}
/**
* A checkbox control filtering the table to records where the given checkbox column is checked.
*/
function CheckboxFilter(props: { column: TableCheckboxColumn }) {
const { column } = props;
const language = useLanguage();
const { checkedColumns, toggleCheckbox } = useTableSearch();
const checked = checkedColumns.has(column.id);
const id = `table-search-checkbox-${column.id}`;
// Fall back to a generic "Filter" label when the column title is hidden/empty.
const label = column.label.trim() || tString(language, 'search_scope_title');
return (
<label
htmlFor={id}
className="flex cursor-pointer select-none items-center gap-1.5 px-2 text-sm text-tint"
>
<Checkbox
id={id}
size="small"
checked={checked}
onCheckedChange={() => toggleCheckbox(column.id)}
/>
{label}
</label>
);
}
type TableSearchRecordProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'className'> & {
/** Record id, matching the `id` of the data passed to `<TableSearchProvider>`. */
recordId: string;
/** Display utility applied when the record matches (e.g. `flex` for rows, `contents` for cards). */
visibleClassName: string;
className?: ClassValue;
children: React.ReactNode;
};
/**
* Wraps a single table record (a grid row or a card) and hides it when it doesn't match
* the current filters. Matching happens once in the provider; here we just look up the id.
*/
export function TableSearchRecord(props: TableSearchRecordProps) {
const { recordId, visibleClassName, className, children, ...rest } = props;
const { visibleIds } = useTableSearch();
const matches = visibleIds === null || visibleIds.has(recordId);
return (
<div className={tcls(matches ? visibleClassName : 'hidden', className)} {...rest}>
{children}
</div>
);
}
@@ -4,6 +4,7 @@ import { tcls } from '@/lib/tailwind';
import { RecordCard } from './RecordCard';
import type { TableViewProps } from './Table';
import { TableSearchRecord } from './TableSearch';
export function ViewCards(props: TableViewProps<DocumentTableViewCards>) {
const { block, view, records, style } = props;
@@ -21,7 +22,15 @@ export function ViewCards(props: TableViewProps<DocumentTableViewCards>) {
)}
>
{records.map((record) => {
return <RecordCard key={record[0]} {...props} record={record} />;
return (
<TableSearchRecord
key={record[0]}
recordId={record[0]}
visibleClassName="contents"
>
<RecordCard {...props} record={record} />
</TableSearchRecord>
);
})}
</div>
);
@@ -0,0 +1,146 @@
import type {
DocumentBlockTable,
DocumentTableRecord,
DocumentTableSelectOption,
} from '@gitbook/api';
import { getNodeFragmentByName, getNodeText } from '@/lib/document';
export type TableRecordKV = [string, DocumentTableRecord];
export interface TableSelectColumn {
/** Column id (the column key in `block.data.definition`). */
id: string;
/** Column title, shown on the filter button. */
label: string;
/** Available options for the column. */
options: DocumentTableSelectOption[];
}
/**
* List the visible "select" columns of a table along with their options.
* Used to render the per-column filter dropdowns next to the search input.
*/
export function getTableSelectColumns(block: DocumentBlockTable): TableSelectColumn[] {
return block.data.view.columns.flatMap((column) => {
const definition = block.data.definition[column];
if (definition?.type !== 'select') {
return [];
}
return [{ id: column, label: definition.title, options: definition.options }];
});
}
export interface TableCheckboxColumn {
/** Column id. */
id: string;
/** Column title, shown next to the checkbox. */
label: string;
}
/**
* List the visible "checkbox" columns of a table.
* Used to render a filter checkbox per column next to the search input.
*/
export function getTableCheckboxColumns(block: DocumentBlockTable): TableCheckboxColumn[] {
return block.data.view.columns.flatMap((column) => {
const definition = block.data.definition[column];
if (definition?.type !== 'checkbox') {
return [];
}
return [{ id: column, label: definition.title }];
});
}
/**
* Build the search data for a record (searchable text + select/checkbox values) in a single
* pass over its columns. Used to feed the client-side table search (see `TableSearch`).
*/
export function getTableRecordSearchData(block: DocumentBlockTable, record: DocumentTableRecord) {
const searchText: string[] = [];
const selectValues: Record<string, string[]> = {};
const checkboxValues: Record<string, boolean> = {};
for (const column of block.data.view.columns) {
const text = getTableCellSearchText(block, record, column);
if (text) {
searchText.push(text);
}
const value = record.values[column];
switch (block.data.definition[column]?.type) {
case 'select':
if (Array.isArray(value)) {
selectValues[column] = value.filter(
(item): item is string => typeof item === 'string'
);
}
break;
case 'checkbox':
if (typeof value === 'boolean') {
checkboxValues[column] = value;
}
break;
}
}
return { searchText: searchText.join(' '), selectValues, checkboxValues };
}
function getTableCellSearchText(
block: DocumentBlockTable,
record: DocumentTableRecord,
column: string
): string {
const definition = block.data.definition[column];
const value = record.values[column];
if (!definition || value === null || value === undefined) {
return '';
}
switch (definition.type) {
case 'text': {
if (typeof value !== 'string') {
return '';
}
const fragment = getNodeFragmentByName(block, value);
return normalizeSearchText(fragment ? getNodeText(fragment) : '');
}
case 'select': {
if (!Array.isArray(value)) {
return '';
}
return normalizeSearchText(
value
.map((selectId) => {
return (
definition.options.find((option) => option.value === selectId)?.label ??
selectId
);
})
.join(' ')
);
}
case 'number':
case 'rating': {
return typeof value === 'number' ? `${value}` : '';
}
case 'checkbox': {
return typeof value === 'boolean' ? `${value}` : '';
}
// Reference-like columns (files, users, content-ref, image) render resolved names/text
// asynchronously in `RecordColumnValue`. We only have raw ids here, so indexing them would
// never match the visible text — leave them out rather than search opaque ids.
default:
return '';
}
}
function normalizeSearchText(text: string): string {
return text.replace(/\s+/g, ' ').trim();
}
@@ -0,0 +1,151 @@
import { describe, expect, it } from 'bun:test';
import { type SelectedOptions, matchesText, recordMatches } from './searchMatch';
const NO_OPTIONS: SelectedOptions = {};
const NO_CHECKBOXES: ReadonlySet<string> = new Set();
function match(
record: {
searchText?: string;
selectValues?: Record<string, string[]>;
checkboxValues?: Record<string, boolean>;
},
filters: {
query?: string;
selectedOptions?: SelectedOptions;
checkedColumns?: ReadonlySet<string>;
}
): boolean {
return recordMatches(
record.searchText ?? '',
record.selectValues,
record.checkboxValues,
filters.query ?? '',
filters.selectedOptions ?? NO_OPTIONS,
filters.checkedColumns ?? NO_CHECKBOXES
);
}
describe('matchesText', () => {
it('matches everything when the query is empty', () => {
expect(matchesText('anything', '')).toBe(true);
expect(matchesText('', ' ')).toBe(true);
});
it('matches case-insensitively', () => {
expect(matchesText('Hello World', 'hello')).toBe(true);
expect(matchesText('Hello World', 'nope')).toBe(false);
});
it('matches regex metacharacters literally', () => {
expect(matchesText('value a+b here', 'a+b')).toBe(true);
expect(matchesText('v1x2', 'v1.2')).toBe(false);
expect(matchesText('a (b) c', '(b)')).toBe(true);
});
});
describe('recordMatches', () => {
it('shows every record when no filter is active', () => {
expect(match({ searchText: 'whatever' }, {})).toBe(true);
});
it('applies the text filter', () => {
expect(match({ searchText: 'Ace AI' }, { query: 'ace' })).toBe(true);
expect(match({ searchText: 'Ace AI' }, { query: 'discrete' })).toBe(false);
});
describe('select columns', () => {
const selectedStatus: SelectedOptions = { status: new Set(['active', 'pending']) };
it('ORs multiple values within a single column', () => {
expect(
match(
{ selectValues: { status: ['pending'] } },
{ selectedOptions: selectedStatus }
)
).toBe(true);
expect(
match(
{ selectValues: { status: ['archived'] } },
{ selectedOptions: selectedStatus }
)
).toBe(false);
});
it('ANDs across different columns', () => {
const filters: SelectedOptions = {
status: new Set(['active']),
tier: new Set(['gold']),
};
expect(
match(
{ selectValues: { status: ['active'], tier: ['gold'] } },
{ selectedOptions: filters }
)
).toBe(true);
// Matches one column but not the other → excluded.
expect(
match(
{ selectValues: { status: ['active'], tier: ['silver'] } },
{ selectedOptions: filters }
)
).toBe(false);
});
it('excludes records missing the column entirely', () => {
expect(match({ selectValues: {} }, { selectedOptions: selectedStatus })).toBe(false);
});
});
describe('checkbox columns', () => {
const featured: ReadonlySet<string> = new Set(['featured']);
it('keeps only records checked for the enabled column', () => {
expect(
match({ checkboxValues: { featured: true } }, { checkedColumns: featured })
).toBe(true);
expect(
match({ checkboxValues: { featured: false } }, { checkedColumns: featured })
).toBe(false);
expect(match({ checkboxValues: {} }, { checkedColumns: featured })).toBe(false);
});
it('ANDs multiple enabled checkbox columns', () => {
const both: ReadonlySet<string> = new Set(['featured', 'inStock']);
expect(
match(
{ checkboxValues: { featured: true, inStock: true } },
{ checkedColumns: both }
)
).toBe(true);
expect(
match(
{ checkboxValues: { featured: true, inStock: false } },
{ checkedColumns: both }
)
).toBe(false);
});
});
it('ANDs the text, select and checkbox filters together', () => {
const record = {
searchText: 'Ace AI',
selectValues: { status: ['active'] },
checkboxValues: { featured: true },
};
const filters = {
query: 'ace',
selectedOptions: { status: new Set(['active']) } satisfies SelectedOptions,
checkedColumns: new Set(['featured']),
};
expect(match(record, filters)).toBe(true);
// Each individual filter failing flips the result to false.
expect(match(record, { ...filters, query: 'discrete' })).toBe(false);
expect(
match(record, { ...filters, selectedOptions: { status: new Set(['archived']) } })
).toBe(false);
expect(match({ ...record, checkboxValues: { featured: false } }, filters)).toBe(false);
});
});
@@ -0,0 +1,65 @@
/**
* Pure matching logic for the client-side table search.
*
* Kept free of React/client dependencies so it can be unit-tested in isolation and shared
* between the search UI and (potentially) other callers.
*/
/** Selected option values per select column, keyed by column id. */
export type SelectedOptions = Readonly<Record<string, ReadonlySet<string>>>;
/**
* Whether a record passes the current filters.
*
* The text query, each select column and each enabled checkbox column are combined with AND:
* a record must match the text (when present), satisfy every column that has a selection, and
* be checked for every enabled checkbox column. Within a single select column the selected
* values are combined with OR — the record matches the column if it has any of them.
*/
export function recordMatches(
searchText: string,
selectValues: Record<string, string[]> | undefined,
checkboxValues: Record<string, boolean> | undefined,
query: string,
selectedOptions: SelectedOptions,
checkedColumns: ReadonlySet<string>
): boolean {
if (query.trim() !== '' && !matchesText(searchText, query)) {
return false;
}
for (const [column, values] of Object.entries(selectedOptions)) {
if (values.size === 0) {
continue;
}
const recordValues = selectValues?.[column];
const matchesColumn = !!recordValues && recordValues.some((value) => values.has(value));
if (!matchesColumn) {
return false;
}
}
for (const column of checkedColumns) {
if (checkboxValues?.[column] !== true) {
return false;
}
}
return true;
}
/**
* Whether a record's searchable text contains the query.
*
* Plain case-insensitive substring match: the field is a free-text search, so regex
* metacharacters (`.`, `+`, `(`, …) are matched literally rather than treated as patterns.
*/
export function matchesText(searchText: string, query: string): boolean {
const trimmed = query.trim();
if (!trimmed) {
return true;
}
return searchText.toLowerCase().includes(trimmed.toLowerCase());
}
@@ -8,6 +8,10 @@ import { PageBody } from '../PageBody';
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
import { categorizeVariants } from '../SpaceLayout/categorizeVariants';
import { TableOfContents } from '../TableOfContents';
import {
TABLE_OF_CONTENTS_SPACES_DROPDOWN_CLASS,
getTableOfContentsInnerHeaderClassName,
} from '../TableOfContents/styles';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { EmbeddableDocsPageControlButtons } from './EmbeddableDocsPageControlButtons';
import {
@@ -129,13 +133,13 @@ export async function EmbeddableDocsPage(
}
innerHeader={
variants.generic.length > 1 ? (
<div className="my-5 sidebar-default:mt-2 flex flex-col gap-2 px-5 empty:hidden">
<div className={getTableOfContentsInnerHeaderClassName()}>
{variants.generic.length > 1 ? (
<SpacesDropdown
context={context}
siteSpace={context.siteSpace}
siteSpaces={variants.generic}
className="w-full px-3"
className={TABLE_OF_CONTENTS_SPACES_DROPDOWN_CLASS}
/>
) : null}
</div>
@@ -35,7 +35,7 @@ export function Footer(props: { context: GitBookSiteContext }) {
mobileOnly ? 'xl:hidden' : null
)}
>
<div className="transition-[padding] duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className="transition-[padding] duration-300 motion-reduce:transition-none lg:chat-open:pr-(--ai-chat-width)">
<div
className={tcls(
CONTAINER_STYLE,
@@ -75,7 +75,7 @@ export async function Header(props: {
'site-header:theme-bold:shadow-tint-12/2'
)}
>
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-(--ai-chat-width)">
<div
data-gb-header-content
className={tcls(
@@ -206,7 +206,7 @@ export async function Header(props: {
</div>
{visibleSections && withSections ? (
<div className="transition-[padding] duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className="transition-[padding] duration-300 motion-reduce:transition-none lg:chat-open:pr-(--ai-chat-width)">
<SiteSectionTabs sections={encodeClientSiteSections(context, visibleSections)}>
{variants.translations.length > 1 ? (
<TranslationsDropdown
@@ -0,0 +1,127 @@
import type { CustomizationSearchStyle } from '@gitbook/api';
import type React from 'react';
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
import { tcls } from '@/lib/tailwind';
const PROMINENT_SEARCH_STYLE: CustomizationSearchStyle = 'prominent' as CustomizationSearchStyle;
/**
* Shared visual layout for the site header.
*
* The live site and structure preview provide different interactive pieces, but the shell,
* spacing, responsive behavior, and theme classes should stay identical.
*/
export function HeaderLayout(props: {
leading: React.ReactNode;
search: React.ReactNode;
searchStyle: CustomizationSearchStyle;
withTopHeader?: boolean;
links?: React.ReactNode;
sections?: React.ReactNode;
}) {
const { leading, search, searchStyle, withTopHeader, links, sections } = props;
const hasProminentSearch = searchStyle === PROMINENT_SEARCH_STYLE;
return (
<header
data-gb-site-header
className={tcls(
'flex',
'flex-col',
`h-[${HEADER_HEIGHT_DESKTOP}px]`,
'sticky',
'top-0',
'pt-[env(safe-area-inset-top)]',
'z-30',
'w-full',
'flex-none',
'shadow-[0px_1px_0px]',
'shadow-tint-12/2',
'bg-tint-base/9',
'theme-muted:bg-tint-subtle/9',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle/9',
'theme-gradient:bg-gradient-primary',
'theme-gradient-tint:bg-gradient-tint',
'contrast-more:bg-tint-base',
withTopHeader ? null : 'mobile-only lg:hidden',
'text-sm',
'backdrop-blur-lg'
)}
>
<div
className={tcls(
'site-header:theme-bold:bg-header-background',
'site-header:theme-bold:shadow-[0px_1px_0px]',
'site-header:theme-bold:shadow-tint-12/2'
)}
>
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
<div
data-gb-header-content
className={tcls(
'gap-4',
'lg:gap-6',
'flex',
'items-center',
'justify-between',
'w-full',
'py-3',
'min-h-16',
'sm:h-16',
CONTAINER_STYLE,
'transition-[max-width] duration-300 motion-reduce:transition-none',
'@container/header'
)}
>
<div
className={tcls(
'flex max-w-full',
'min-w-0 shrink items-center justify-start gap-2 lg:gap-4',
hasProminentSearch ? 'lg:@2xl:basis-72' : null
)}
>
{leading}
</div>
<div
className={tcls(
'flex',
'grow-0',
'shrink-0',
'md:@2xl:basis-56',
'justify-self-end',
'items-center',
'gap-2',
'transition-[margin] duration-300 motion-reduce:transition-none',
hasProminentSearch
? [
'md:@2xl:grow-[0.8]',
'md:@4xl:basis-40',
'md:@2xl:max-w-[50%]',
'md:@4xl:max-w-lg',
'lg:@2xl:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]',
'not-chat-open:xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]',
'md:@2xl:mr-auto',
'order-last',
'md:@2xl:order-[unset]',
]
: ['order-last']
)}
>
{search}
</div>
{links}
</div>
</div>
</div>
{sections ? (
<div className="transition-[padding] duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
{sections}
</div>
) : null}
</header>
);
}
@@ -1,16 +1,10 @@
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
import type { GitBookSiteContext } from '@/lib/context';
import {
type CustomizationContentLink,
type CustomizationHeaderItem,
SiteInsightsLinkPosition,
} from '@gitbook/api';
import type { CustomizationContentLink, CustomizationHeaderItem } from '@gitbook/api';
import { resolveContentRef } from '@/lib/references';
import { getLocalizedTitle } from '@/lib/sites';
import { SiteAuthLoginDropdownMenuItem } from '../SiteAuth/SiteAuthLoginLink';
import { DropdownMenuItem } from '../primitives/DropdownMenu';
import { HeaderLinkDropdown, HeaderLinkNavItem } from './HeaderLinkDropdown';
import { HeaderLinkItem, SubHeaderLinkItem } from './HeaderLinkClient';
import { getHeaderLinkDropdownClassName } from './HeaderLinkStyles';
export async function HeaderLink(props: {
context: GitBookSiteContext;
@@ -20,45 +14,21 @@ export async function HeaderLink(props: {
const { customization } = context;
const target = link.to ? await resolveContentRef(link.to, context) : null;
const headerPreset = customization.header.preset;
const linkStyle = link.style ?? 'link';
const title = getLocalizedTitle(link, context.locale);
if (link.links && link.links.length > 0) {
return (
<HeaderLinkDropdown
headerPreset={headerPreset}
title={title}
hasTarget={!!target}
linkTarget={link.to}
linkStyle={linkStyle}
href={target?.href}
isSiteAuthLoginHref={
target ? isSiteAuthLoginHref(context.linker, target.href) : false
}
dropdownClassName={`shrink ${customization.styling.search === 'prominent' ? 'right-0 left-auto' : null}`}
>
{link.links.map((subLink, index) => (
<SubHeaderLink key={index} {...props} link={subLink} />
))}
</HeaderLinkDropdown>
);
}
if (!link.to) {
return null;
}
return (
<HeaderLinkNavItem
linkTarget={link.to}
linkStyle={linkStyle}
headerPreset={headerPreset}
title={title}
isDropdown={false}
<HeaderLinkItem
link={link}
locale={context.locale}
headerPreset={customization.header.preset}
hasTarget={!!target}
href={target?.href}
isSiteAuthLoginHref={target ? isSiteAuthLoginHref(context.linker, target.href) : false}
/>
dropdownClassName={getHeaderLinkDropdownClassName(customization.styling.search)}
>
{link.links?.map((subLink, index) => (
<SubHeaderLink key={index} {...props} link={subLink} />
))}
</HeaderLinkItem>
);
}
@@ -74,21 +44,12 @@ async function SubHeaderLink(props: {
return null;
}
const title = getLocalizedTitle(link, context.locale);
const sharedProps = {
href: target.href,
insights: {
type: 'link_click' as const,
link: {
target: link.to,
position: SiteInsightsLinkPosition.Header,
},
},
};
return isSiteAuthLoginHref(context.linker, target.href) ? (
<SiteAuthLoginDropdownMenuItem {...sharedProps}>{title}</SiteAuthLoginDropdownMenuItem>
) : (
<DropdownMenuItem {...sharedProps}>{title}</DropdownMenuItem>
return (
<SubHeaderLinkItem
link={link}
locale={context.locale}
href={target.href}
isSiteAuthLoginHref={isSiteAuthLoginHref(context.linker, target.href)}
/>
);
}
@@ -0,0 +1,124 @@
'use client';
import {
type CustomizationContentLink,
type CustomizationHeaderItem,
type CustomizationHeaderPreset,
SiteInsightsLinkPosition,
type TranslationLanguage,
} from '@gitbook/api';
import type React from 'react';
import { getLocalizedTitle } from '@/lib/sites';
import { SiteAuthLoginDropdownMenuItem } from '../SiteAuth/SiteAuthLoginLink';
import { DropdownMenuItem, DropdownSubMenu } from '../primitives/DropdownMenu';
import { HeaderLinkDropdown, HeaderLinkNavItem } from './HeaderLinkDropdown';
type HeaderLinkStyle = 'link' | 'button-secondary' | 'button-primary';
export function HeaderLinkItem(props: {
link: CustomizationHeaderItem;
locale: TranslationLanguage | undefined;
headerPreset: CustomizationHeaderPreset;
dropdownClassName: string | null;
href?: string;
hasTarget: boolean;
isSiteAuthLoginHref?: boolean;
children?: React.ReactNode;
}) {
const {
link,
locale,
headerPreset,
dropdownClassName,
href,
hasTarget,
isSiteAuthLoginHref = false,
children,
} = props;
const linkStyle = (link.style ?? 'link') satisfies HeaderLinkStyle;
const title = getLocalizedTitle(link, locale);
if (link.links && link.links.length > 0) {
return (
<HeaderLinkDropdown
headerPreset={headerPreset}
title={title}
hasTarget={hasTarget}
linkTarget={link.to ?? null}
linkStyle={linkStyle}
href={href}
isSiteAuthLoginHref={isSiteAuthLoginHref}
dropdownClassName={dropdownClassName ?? ''}
>
{children}
</HeaderLinkDropdown>
);
}
if (!link.to) {
return null;
}
return (
<HeaderLinkNavItem
linkTarget={link.to}
linkStyle={linkStyle}
headerPreset={headerPreset}
title={title}
isDropdown={false}
href={href}
isSiteAuthLoginHref={isSiteAuthLoginHref}
/>
);
}
export function SubHeaderLinkItem(props: {
link: CustomizationContentLink;
locale: TranslationLanguage | undefined;
href?: string;
isSiteAuthLoginHref?: boolean;
}) {
return <HeaderLinkMenuItem {...props} />;
}
export function HeaderLinkSubMenu(props: {
link: CustomizationHeaderItem;
locale: TranslationLanguage | undefined;
children: React.ReactNode;
}) {
const { link, locale, children } = props;
const title = getLocalizedTitle(link, locale);
return <DropdownSubMenu label={title}>{children}</DropdownSubMenu>;
}
export function HeaderLinkMenuItem(props: {
link: CustomizationHeaderItem | CustomizationContentLink;
locale: TranslationLanguage | undefined;
href?: string;
isSiteAuthLoginHref?: boolean;
}) {
const { link, locale, href, isSiteAuthLoginHref = false } = props;
const title = getLocalizedTitle(link, locale);
const sharedProps = {
href,
insights: link.to
? {
type: 'link_click' as const,
link: {
target: link.to,
position: SiteInsightsLinkPosition.Header,
},
}
: undefined,
};
return isSiteAuthLoginHref && href ? (
<SiteAuthLoginDropdownMenuItem {...sharedProps} href={href}>
{title}
</SiteAuthLoginDropdownMenuItem>
) : (
<DropdownMenuItem {...sharedProps}>{title}</DropdownMenuItem>
);
}
@@ -1,25 +1,19 @@
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
import type { GitBookSiteContext } from '@/lib/context';
import {
type CustomizationContentLink,
type CustomizationHeaderItem,
SiteInsightsLinkPosition,
type SiteSocialAccount,
import type {
CustomizationContentLink,
CustomizationHeaderItem,
SiteSocialAccount,
} from '@gitbook/api';
import type React from 'react';
import { resolveContentRef } from '@/lib/references';
import { getLocalizedTitle } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { SocialAccountLink } from '../Footer/SocialAccounts';
import { SiteAuthLoginDropdownMenuItem } from '../SiteAuth/SiteAuthLoginLink';
import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownSubMenu,
} from '../primitives/DropdownMenu';
import { DropdownMenuSeparator } from '../primitives/DropdownMenu';
import { HeaderLinkMenuItem, HeaderLinkSubMenu } from './HeaderLinkClient';
import { HeaderLinkMoreDropdown } from './HeaderLinkMoreClient';
import { getHeaderLinkMoreDropdownClassName } from './HeaderLinkStyles';
import styles from './headerLinks.module.css';
/**
@@ -37,9 +31,8 @@ export function HeaderLinkMore(props: {
<div className={`${styles.linkEllipsis} z-20 items-center`}>
<HeaderLinkMoreDropdown
label={label}
dropdownClassName={tcls(
'max-md:right-0 max-md:left-auto',
context.customization.styling.search === 'prominent' && 'right-0 left-auto'
dropdownClassName={getHeaderLinkMoreDropdownClassName(
context.customization.styling.search
)}
>
{links.map((link, index) => (
@@ -63,32 +56,20 @@ async function MoreMenuLink(props: {
}) {
const { context, link } = props;
const title = getLocalizedTitle(link, context.locale);
const target = link.to ? await resolveContentRef(link.to, context) : null;
const sharedProps = {
href: target?.href,
insights: link.to
? {
type: 'link_click' as const,
link: {
target: link.to,
position: SiteInsightsLinkPosition.Header,
},
}
: undefined,
};
return 'links' in link && link.links.length > 0 ? (
<DropdownSubMenu label={title}>
<HeaderLinkSubMenu link={link} locale={context.locale}>
{link.links.map((subLink, index) => {
return <MoreMenuLink key={index} {...props} link={subLink} />;
})}
</DropdownSubMenu>
) : isSiteAuthLoginHref(context.linker, target?.href) && sharedProps.href ? (
<SiteAuthLoginDropdownMenuItem {...sharedProps} href={sharedProps.href}>
{title}
</SiteAuthLoginDropdownMenuItem>
</HeaderLinkSubMenu>
) : (
<DropdownMenuItem {...sharedProps}>{title}</DropdownMenuItem>
<HeaderLinkMenuItem
link={link}
locale={context.locale}
href={target?.href}
isSiteAuthLoginHref={isSiteAuthLoginHref(context.linker, target?.href)}
/>
);
}
@@ -0,0 +1,17 @@
import { CustomizationSearchStyle } from '@gitbook/api';
import { tcls } from '@/lib/tailwind';
export function getHeaderLinkDropdownClassName(searchStyle: CustomizationSearchStyle) {
return tcls(
'shrink',
searchStyle === CustomizationSearchStyle.Prominent && 'right-0 left-auto'
);
}
export function getHeaderLinkMoreDropdownClassName(searchStyle: CustomizationSearchStyle) {
return tcls(
'max-md:right-0 max-md:left-auto',
searchStyle === CustomizationSearchStyle.Prominent && 'right-0 left-auto'
);
}
@@ -9,7 +9,7 @@ interface HeaderLinksProps {
style?: ClassValue;
}
export async function HeaderLinks({ children, style }: HeaderLinksProps) {
export function HeaderLinks({ children, style }: HeaderLinksProps) {
return (
<div
className={tcls(
@@ -1,11 +1,16 @@
import type { GitBookSiteContext } from '@/lib/context';
import { Image } from '@/components/utils';
import { tcls } from '@/lib/tailwind';
import { resolveContentRef } from '@/lib/references';
import { Link } from '../primitives';
import { CurrentContentIcon } from './CurrentContentIcon';
import {
HEADER_LOGO_CONTAINER_CLASS,
HEADER_LOGO_IMAGE_CLASS,
HEADER_LOGO_IMAGE_SIZES,
HeaderLogoContent,
} from './HeaderLogoContent';
interface HeaderLogoProps {
context: GitBookSiteContext;
@@ -26,83 +31,56 @@ export async function HeaderLogo(props: HeaderLogoProps) {
return (
<Link
href={primaryLink?.href ?? linker.toPathInSite('')}
className={tcls('group/headerlogo', 'min-w-0', 'shrink', 'flex', 'items-center')}
className={HEADER_LOGO_CONTAINER_CLASS}
>
{customization.header.logo ? (
<Image
alt="Logo"
resize={context.imageResizer}
sources={{
light: {
src: customization.header.logo.light,
},
dark: customization.header.logo.dark
? {
src: customization.header.logo.dark,
}
: null,
}}
sizes={[
{
media: '(max-width: 1024px)',
width: 160,
},
{
width: 260,
},
]}
preload
style={tcls(
'overflow-hidden',
'shrink',
'min-w-0',
'max-w-40',
'lg:max-w-64',
'lg:site-header-none:page-no-toc:max-w-56',
'max-h-8',
'h-full',
'w-full',
'object-contain',
'object-left'
)}
/>
) : (
<LogoFallback {...props} />
)}
<HeaderLogoContent
logo={customization.header.logo ? <LogoImage context={context} /> : null}
fallbackIcon={<LogoFallbackIcon context={context} />}
title={context.site.title}
/>
</Link>
);
}
function LogoFallback(props: HeaderLogoProps) {
function LogoImage(props: HeaderLogoProps) {
const { context } = props;
const { site } = context;
const { customization } = context;
if (!customization.header.logo) {
return null;
}
return (
<>
<CurrentContentIcon
context={context}
alt=""
sizes={[{ width: 32 }]}
style={['object-contain', 'size-8']}
fetchPriority="high"
/>
<div
className={tcls(
'text-pretty',
'line-clamp-2',
'tracking-tight',
'max-w-[18ch]',
'lg:max-w-[24ch]',
'font-semibold',
'ms-3',
'text-base/tight',
'lg:text-lg/tight',
'text-tint-strong',
'theme-bold:text-header-link'
)}
>
{site.title}
</div>
</>
<Image
alt="Logo"
resize={context.imageResizer}
sources={{
light: {
src: customization.header.logo.light,
},
dark: customization.header.logo.dark
? {
src: customization.header.logo.dark,
}
: null,
}}
sizes={HEADER_LOGO_IMAGE_SIZES}
preload
style={HEADER_LOGO_IMAGE_CLASS}
/>
);
}
function LogoFallbackIcon(props: HeaderLogoProps) {
const { context } = props;
return (
<CurrentContentIcon
context={context}
alt=""
sizes={[{ width: 32 }]}
style={['object-contain', 'size-8']}
fetchPriority="high"
/>
);
}
@@ -0,0 +1,78 @@
import type { ReactNode } from 'react';
import { tcls } from '@/lib/tailwind';
export const HEADER_LOGO_IMAGE_SIZES = [
{
media: '(max-width: 1024px)',
width: 160,
},
{
width: 260,
},
];
export const HEADER_LOGO_CONTAINER_CLASS = tcls(
'group/headerlogo',
'min-w-0',
'shrink',
'flex',
'items-center'
);
export const HEADER_LOGO_IMAGE_CLASS = tcls(
'overflow-hidden',
'shrink',
'min-w-0',
'max-w-40',
'lg:max-w-64',
'lg:site-header-none:page-no-toc:max-w-56',
'max-h-8',
'h-full',
'w-full',
'object-contain',
'object-left'
);
interface HeaderLogoContentProps {
logo: ReactNode | null;
fallbackIcon: ReactNode;
title: ReactNode;
}
export function HeaderLogoContent(props: HeaderLogoContentProps) {
const { logo, fallbackIcon, title } = props;
if (logo) {
return logo;
}
return (
<>
{fallbackIcon}
<HeaderLogoTitle>{title}</HeaderLogoTitle>
</>
);
}
function HeaderLogoTitle(props: { children: ReactNode }) {
return (
<div
className={tcls(
'text-pretty',
'line-clamp-2',
'tracking-tight',
'max-w-[18ch]',
'lg:max-w-[24ch]',
'font-semibold',
'ms-3',
'text-base/tight',
'lg:text-lg/tight',
'text-tint-strong',
'theme-bold:text-header-link'
)}
>
{props.children}
</div>
);
}
@@ -2,17 +2,15 @@ import type { SiteSpace } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import type { GitBookSiteContext } from '@/lib/context';
import { getLocalizedTitle, getSiteSpaceURL } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { getSiteSpaceURL } from '@/lib/sites';
import type { ButtonProps } from '../primitives';
import { SpacesDropdownClient } from './SpacesDropdownClient';
// Memoized regex for checking if a string starts with an emoji
const EMOJI_REGEX = /^\p{Emoji}/u;
function startsWithEmoji(text: string): boolean {
return EMOJI_REGEX.test(text);
}
import {
getSlimSiteSpaces,
getSpacesDropdownMenuClassName,
getSpacesDropdownTitle,
getTranslationsDropdownClassName,
} from './SpacesDropdownData';
export function SpacesDropdown(props: {
context: GitBookSiteContext;
@@ -25,26 +23,20 @@ export function SpacesDropdown(props: {
const { context, siteSpace, siteSpaces, className, variant = 'secondary', icon } = props;
const currentLanguage = context.locale;
const dropdownClassName = tcls(
'group-hover/dropdown:invisible', // Prevent hover from opening the dropdown, as it's annoying in this context
'group-focus-within/dropdown:group-hover/dropdown:visible' // When the dropdown is already open, it should remain visible when hovered
);
const slimSpaces = siteSpaces.map((siteSp) => ({
id: siteSp.id,
title: getLocalizedTitle(siteSp, currentLanguage),
url: getSiteSpaceURL(context, siteSp),
isActive: siteSp.id === siteSpace.id,
spaceId: siteSp.space.id,
}));
const slimSpaces = getSlimSiteSpaces({
siteSpace,
siteSpaces,
currentLanguage,
getURL: (siteSp) => getSiteSpaceURL(context, siteSp),
});
return (
<SpacesDropdownClient
title={getLocalizedTitle(siteSpace, currentLanguage)}
title={getSpacesDropdownTitle(siteSpace, currentLanguage)}
icon={icon}
variant={variant}
className={className}
dropdownClassName={dropdownClassName}
dropdownClassName={getSpacesDropdownMenuClassName()}
slimSpaces={slimSpaces}
curPath={siteSpace.path}
/>
@@ -59,8 +51,7 @@ export function TranslationsDropdown(props: {
}) {
const { context, siteSpace, siteSpaces, className } = props;
const title = getLocalizedTitle(siteSpace, context.locale);
const hasEmojiPrefix = startsWithEmoji(title);
const title = getSpacesDropdownTitle(siteSpace, context.locale);
return (
<SpacesDropdown
@@ -69,13 +60,7 @@ export function TranslationsDropdown(props: {
siteSpace={siteSpace}
siteSpaces={siteSpaces}
variant="blank"
className={tcls(
'-mx-3 bg-transparent lg:max-w-64 max-md:[&_.button-content]:hidden',
hasEmojiPrefix
? 'md:[&_.button-leading-icon]:hidden' // If the title starts with an emoji, don't show the icon (on desktop)
: '',
className
)}
className={getTranslationsDropdownClassName({ title, className })}
/>
);
}
@@ -5,6 +5,7 @@ import type { IconName } from '@gitbook/icons';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { Button, type ButtonProps, ToggleChevron } from '../primitives';
import { DropdownMenu } from '../primitives/DropdownMenu';
import type { SlimSiteSpace } from './SpacesDropdownData';
import { SpacesDropdownMenuItems } from './SpacesDropdownMenuItem';
/**
@@ -17,16 +18,12 @@ export function SpacesDropdownClient(props: {
variant: ButtonProps['variant'];
className?: ClassValue;
dropdownClassName: string;
slimSpaces: Array<{
id: string;
title: string;
url: string;
isActive: boolean;
spaceId: string;
}>;
slimSpaces: SlimSiteSpace[];
curPath: string;
clickable?: boolean;
}) {
const { title, icon, variant, className, dropdownClassName, slimSpaces, curPath } = props;
const { title, icon, variant, className, dropdownClassName, slimSpaces, curPath, clickable } =
props;
return (
<DropdownMenu
@@ -44,7 +41,11 @@ export function SpacesDropdownClient(props: {
</Button>
}
>
<SpacesDropdownMenuItems slimSpaces={slimSpaces} curPath={curPath} />
<SpacesDropdownMenuItems
slimSpaces={slimSpaces}
curPath={curPath}
clickable={clickable}
/>
</DropdownMenu>
);
}
@@ -0,0 +1,66 @@
import type { SiteSpace, TranslationLanguage } from '@gitbook/api';
import { getLocalizedTitle } from '@/lib/sites';
import { type ClassValue, tcls } from '@/lib/tailwind';
export type SlimSiteSpace = {
id: string;
title: string;
url: string;
isActive: boolean;
spaceId: string;
};
// Memoized regex for checking if a string starts with an emoji
const EMOJI_REGEX = /^\p{Emoji}/u;
function startsWithEmoji(text: string): boolean {
return EMOJI_REGEX.test(text);
}
export function getSpacesDropdownTitle(
siteSpace: SiteSpace,
currentLanguage: TranslationLanguage | undefined
) {
return getLocalizedTitle(siteSpace, currentLanguage);
}
export function getSlimSiteSpaces(props: {
siteSpace: SiteSpace;
siteSpaces: SiteSpace[];
currentLanguage: TranslationLanguage | undefined;
getURL: (siteSpace: SiteSpace) => string;
}): SlimSiteSpace[] {
const { siteSpace, siteSpaces, currentLanguage, getURL } = props;
return siteSpaces.map((siteSp) => ({
id: siteSp.id,
title: getSpacesDropdownTitle(siteSp, currentLanguage),
url: getURL(siteSp),
isActive: siteSp.id === siteSpace.id,
spaceId: siteSp.space.id,
}));
}
export function getTranslationsDropdownClassName(props: {
title: string;
className?: ClassValue;
}) {
const { title, className } = props;
const hasEmojiPrefix = startsWithEmoji(title);
return tcls(
'-mx-3 bg-transparent lg:max-w-64 max-md:[&_.button-content]:hidden',
hasEmojiPrefix
? 'md:[&_.button-leading-icon]:hidden' // If the title starts with an emoji, don't show the icon (on desktop)
: '',
className
);
}
export function getSpacesDropdownMenuClassName() {
return tcls(
'group-hover/dropdown:invisible', // Prevent hover from opening the dropdown, as it's annoying in this context
'group-focus-within/dropdown:group-hover/dropdown:visible' // When the dropdown is already open, it should remain visible when hovered
);
}
@@ -3,19 +3,16 @@
import { joinPath } from '@/lib/paths';
import { useCurrentPageMetadata, useCurrentPagePath } from '../hooks';
import { DropdownMenuItem } from '../primitives/DropdownMenu';
interface VariantSpace {
id: string;
title: string;
url: string;
isActive: boolean;
spaceId: string;
}
import type { SlimSiteSpace } from './SpacesDropdownData';
/**
* Return the href for a variant space, taking into account the current page path and metadata.
*/
function useVariantSpaceHref(variantSpace: VariantSpace, currentSpacePath: string, active = false) {
function useVariantSpaceHref(
variantSpace: SlimSiteSpace,
currentSpacePath: string,
active = false
) {
const currentPathname = useCurrentPagePath();
const { metaLinks } = useCurrentPageMetadata();
@@ -52,7 +49,7 @@ function useVariantSpaceHref(variantSpace: VariantSpace, currentSpacePath: strin
}
export function SpacesDropdownMenuItem(props: {
variantSpace: VariantSpace;
variantSpace: SlimSiteSpace;
active: boolean;
currentSpacePath: string;
}) {
@@ -66,22 +63,40 @@ export function SpacesDropdownMenuItem(props: {
);
}
export function SpacesDropdownMenuItems(props: {
slimSpaces: VariantSpace[];
curPath: string;
function StaticSpacesDropdownMenuItem(props: {
variantSpace: SlimSiteSpace;
active: boolean;
}) {
const { slimSpaces, curPath } = props;
const { variantSpace, active } = props;
return <DropdownMenuItem active={active}>{variantSpace.title}</DropdownMenuItem>;
}
export function SpacesDropdownMenuItems(props: {
slimSpaces: SlimSiteSpace[];
curPath: string;
clickable?: boolean;
}) {
const { slimSpaces, curPath, clickable = true } = props;
return (
<>
{slimSpaces.map((space) => (
<SpacesDropdownMenuItem
key={space.id}
variantSpace={space}
active={space.isActive}
currentSpacePath={curPath}
/>
))}
{slimSpaces.map((space) =>
clickable ? (
<SpacesDropdownMenuItem
key={space.id}
variantSpace={space}
active={space.isActive}
currentSpacePath={curPath}
/>
) : (
<StaticSpacesDropdownMenuItem
key={space.id}
variantSpace={space}
active={space.isActive}
/>
)
)}
</>
);
}
@@ -4,8 +4,8 @@ import { useAIChatController, useAIChatState } from '@/components/AI';
import type { Assistant } from '@/components/AI';
import { Button } from '@/components/primitives/Button';
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
import { getURLForLLM } from '@/components/utils';
import { tString, useLanguage } from '@/intl/client';
import type { TranslationLanguage } from '@/intl/translations';
import type { GitSyncState } from '@gitbook/api';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import assertNever from 'assert-never';
@@ -206,7 +206,7 @@ export function ActionOpenInLLM(props: {
const language = useLanguage();
const providerLabel = provider === 'chatgpt' ? 'ChatGPT' : 'Claude';
const prompt = tString(language, 'open_in_llms_pre_prompt', url);
return (
<PageActionWrapper
type={type}
@@ -214,7 +214,7 @@ export function ActionOpenInLLM(props: {
label={tString(language, 'open_in', providerLabel)}
shortLabel={providerLabel}
description={tString(language, 'ai_chat_ask_about_page', providerLabel)}
href={getLLMURL(provider, url, language)}
href={getURLForLLM(provider, prompt)}
/>
);
}
@@ -512,19 +512,3 @@ function PageActionWrapper(props: {
</DropdownMenuItem>
);
}
/**
* Returns the URL to open the page in a LLM with a pre-filled prompt.
*/
function getLLMURL(provider: 'chatgpt' | 'claude', url: string, language: TranslationLanguage) {
const prompt = encodeURIComponent(tString(language, 'open_in_llms_pre_prompt', url));
switch (provider) {
case 'chatgpt':
return `https://chat.openai.com/?q=${prompt}`;
case 'claude':
return `https://claude.ai/new?q=${prompt}`;
default:
assertNever(provider);
}
}
@@ -7,6 +7,7 @@ import type { DocumentSection } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import { useBodyLoaded } from '@/components/primitives';
import { OpenAPIMethodBadge } from '@gitbook/react-openapi';
import { HEADER_HEIGHT_DESKTOP } from '../layout';
/**
@@ -141,11 +142,11 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
)}
>
{section.tag ? (
<span
className={`-mt-0.5 openapi-method text-xs! openapi-method-${section.tag.toLowerCase()}`}
>
{section.tag}
</span>
<OpenAPIMethodBadge
method={section.tag}
size="small"
className="-mt-0.5"
/>
) : null}
<span
@@ -106,18 +106,20 @@ export async function PageBody(props: {
fallback={<DocumentViewSkeleton document={document} blockStyle="" />}
>
<SuspenseLoadedHint />
<DocumentView
document={document}
style="flex flex-col [&>*+*]:mt-5"
context={{
mode: 'default',
contentContext: {
...context,
page,
},
withLinkPreviews,
}}
/>
<div className="contents" data-content-ref-root="">
<DocumentView
document={document}
style="flex flex-col [&>*+*]:mt-5"
context={{
mode: 'default',
contentContext: {
...context,
page,
},
withLinkPreviews,
}}
/>
</div>
</OptionalSuspense>
) : (
<PageBodyBlankslate page={page} context={context} />
@@ -2,7 +2,12 @@ import type { GitBookSiteContext } from '@/lib/context';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { getPageRSSURL } from '@/routes/rss';
import { CustomizationAIMode, type RevisionPageDocument, SiteVisibility } from '@gitbook/api';
import {
CustomizationAIMode,
CustomizationPageActionType,
type RevisionPageDocument,
SiteVisibility,
} from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import urlJoin from 'url-join';
import { getPDFURLSearchParams } from '../PDF';
@@ -29,52 +34,29 @@ export async function PageHeader(props: {
const pageActionsEnabled = page.layout.actions !== false;
// Show page actions if *any* of the actions are enabled
const hasPageActions =
pageActionsEnabled &&
[
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,
].some(Boolean);
// Show page actions if *any* of the configured actions are enabled, or if the RSS feed is
// available. RSS is contextual (only on update/blog index pages) and is not part of the
// configured `items` list, so it is checked separately.
const hasConfiguredPageActions = [
CustomizationPageActionType.Assistant,
CustomizationPageActionType.ExternalAi,
CustomizationPageActionType.Markdown,
CustomizationPageActionType.Mcp,
CustomizationPageActionType.Pdf,
CustomizationPageActionType.Git,
].some((type) => isPageActionEnabled(context.customization, type));
const hasPageActions = pageActionsEnabled && (hasConfiguredPageActions || withRSSFeed);
if (!page.layout.title && !page.layout.description && !hasPageActions) {
return null;
}
return (
<>
{/* Page actions (assistant, "On this page", ...). Rendered as a sibling of the
<header> — i.e. a direct child of the scrolling <main> — so that on larger screens
it can stick to the top of the viewport while the page is scrolled. If it lived
inside the <header>, its sticky containing block would be the short header and it
would scroll away with it. */}
<header className={tcls(CONTENT_STYLE, 'mb-6 space-y-3 after:clear-both after:block')}>
<div
className={tcls(
'float-right ml-4 flex gap-2',
hasAncestors ? '-my-0.5' : '-mt-3 xs:mt-2',
// Stick to the top of the viewport on non-mobile viewports so the actions
// remain reachable while reading long pages (e.g. API references).
'lg:sticky lg:z-20',
// When the "On this page" panel is expanded as an overlay, drop below it
// so the sticky actions don't show on top of the open panel.
'lg:[body.outline-open_&]:z-0',
// Server-side static positioning, padded slightly below the site header
// (unlike the TOC/outline, which sit flush against it).
'lg:top-3',
'lg:site-header:top-[4.75rem]',
'lg:site-header-sections:top-[7.5rem]',
// Client-side dynamic positioning (CSS var applied by TableOfContentsScript),
// kept consistent with the outline so both account for headers/banners/covers.
'lg:[html[style*="--outline-top-offset"]_&]:top-[calc(var(--outline-top-offset)+0.75rem)]!'
hasAncestors ? '-my-0.5' : '-mt-3 xs:mt-2'
)}
>
{hasPageActions ? (
@@ -93,87 +75,78 @@ export async function PageHeader(props: {
<PageAsideToggleButton />
</div>
<header className={tcls(CONTENT_STYLE, 'mb-6 space-y-3 after:clear-both after:block')}>
{hasAncestors && (
<nav aria-label="Breadcrumb" className="text-tint leading-snug">
<ol className="inline">
{ancestors.map((breadcrumb, index) => {
const href = linker.toPathForPage({
pages: revision.pages,
page: breadcrumb,
});
return (
<li key={breadcrumb.id} className="inline">
<StyledLink
href={href}
className={tcls(
'inline',
'no-underline',
'hover:underline',
'text-xs',
'tracking-wide',
'font-semibold',
'uppercase',
'items-center',
'contrast-more:underline',
'contrast-more:decoration-current'
)}
>
<PageIcon
page={breadcrumb}
style="mr-1 inline size-3.5 shrink-0"
/>
{breadcrumb.title}
</StyledLink>
{index !== ancestors.length - 1 && (
<Icon
aria-hidden
icon="chevron-right"
className="mx-2 inline-flex size-2 text-tint-subtle"
/>
{hasAncestors && (
<nav aria-label="Breadcrumb" className="text-tint leading-snug">
<ol className="inline">
{ancestors.map((breadcrumb, index) => {
const href = linker.toPathForPage({
pages: revision.pages,
page: breadcrumb,
});
return (
<li key={breadcrumb.id} className="inline">
<StyledLink
href={href}
className={tcls(
'inline',
'no-underline',
'hover:underline',
'text-xs',
'tracking-wide',
'font-semibold',
'uppercase',
'items-center',
'contrast-more:underline',
'contrast-more:decoration-current'
)}
</li>
);
})}
</ol>
</nav>
)}
<PageTags page={page} revision={revision} />
{page.layout.title ? (
<h1
className={tcls(
'text-2xl',
'@xs:text-3xl',
'@lg:text-4xl',
'leading-tight',
'font-bold',
'flex',
'items-center',
'gap-[.5em]',
'grow',
'text-pretty',
'clear-right',
'xs:clear-none'
)}
>
<PageIcon page={page} style={['text-tint-subtle ', 'shrink-0']} />
{page.title}
</h1>
) : null}
{page.description && page.layout.description ? (
<p
className={tcls(
CONTENT_STYLE_REDUCED,
'text-lg',
'text-tint',
'clear-both'
)}
>
{page.description}
</p>
) : null}
</header>
</>
>
<PageIcon
page={breadcrumb}
style="mr-1 inline size-3.5 shrink-0"
/>
{breadcrumb.title}
</StyledLink>
{index !== ancestors.length - 1 && (
<Icon
aria-hidden
icon="chevron-right"
className="mx-2 inline-flex size-2 text-tint-subtle"
/>
)}
</li>
);
})}
</ol>
</nav>
)}
<PageTags page={page} revision={revision} />
{page.layout.title ? (
<h1
className={tcls(
'text-2xl',
'@xs:text-3xl',
'@lg:text-4xl',
'leading-tight',
'font-bold',
'flex',
'items-center',
'gap-[.5em]',
'grow',
'text-pretty',
'clear-right',
'xs:clear-none'
)}
>
<PageIcon page={page} style={['text-tint-subtle ', 'shrink-0']} />
{page.title}
</h1>
) : null}
{page.description && page.layout.description ? (
<p className={tcls(CONTENT_STYLE_REDUCED, 'text-lg', 'text-tint', 'clear-both')}>
{page.description}
</p>
) : null}
</header>
);
}
@@ -196,13 +169,15 @@ function getPageActionsURLs({
markdown: `${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`,
rss: withRSSFeed ? getPageRSSURL(context, page) : undefined,
editOnGit:
context.customization.git.showEditLink && context.space.gitSync?.url && page.git
isPageActionEnabled(context.customization, CustomizationPageActionType.Git) &&
context.space.gitSync?.url &&
page.git
? {
provider: context.space?.gitSync?.installationProvider,
url: urlJoin(context.space.gitSync.url, page.git.path),
}
: undefined,
pdf: context.customization.pdf.enabled
pdf: isPageActionEnabled(context.customization, CustomizationPageActionType.Pdf)
? context.linker.toPathInSpace(
`~gitbook/pdf?${getPDFURLSearchParams({
page: page.id,
@@ -215,6 +190,37 @@ function getPageActionsURLs({
};
}
/**
* Whether a given built-in page action is enabled. Uses the configured `items` list when the API
* provides it, and falls back to the deprecated boolean flags otherwise (legacy mode), matching the
* fallback used by the page actions dropdown.
*/
function isPageActionEnabled(
customization: GitBookSiteContext['customization'],
type: CustomizationPageActionType
): boolean {
const { pageActions } = customization;
if (pageActions.items) {
return pageActions.items.includes(type);
}
switch (type) {
case CustomizationPageActionType.ExternalAi:
return pageActions.externalAI;
case CustomizationPageActionType.Markdown:
return pageActions.markdown;
case CustomizationPageActionType.Mcp:
return pageActions.mcp;
case CustomizationPageActionType.Pdf:
return customization.pdf.enabled;
case CustomizationPageActionType.Git:
return customization.git.showEditLink;
case CustomizationPageActionType.Assistant:
return customization.ai.mode === CustomizationAIMode.Assistant;
default:
return false;
}
}
/**
* Return the MCP URL to be used in the page actions dropdown.
*/
@@ -211,6 +211,7 @@
@apply leading-relaxed;
interpolate-size: allow-keywords; /* Opt-in for modern browsers to interpolate "auto" values in transitions/animations. */
overflow-x: hidden; /* We never want horizontal scroll of the whole page, it looks buggy and we should never have overflow anyway */
--ai-chat-width: 24rem; /* Default AI chat panel width (= AI_CHAT_DEFAULT_WIDTH 384px); overridden client-side from local storage. */
}
/* Modern browsers with `scrollbar-*` support */
@@ -305,6 +306,15 @@ html.dark {
color-scheme: dark light;
}
/** While the AI chat panel is being resized, suppress transitions */
html[data-ai-chat-resizing="true"] {
cursor: col-resize;
user-select: none;
}
html[data-ai-chat-resizing="true"] * {
transition-duration: 0s !important;
}
html.announcement-hidden [data-gb-announcement-banner] {
@apply hidden;
}
@@ -11,6 +11,7 @@ import { Button, Popover } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
import { SideSheet } from '../primitives/SideSheet';
import { SearchFrame } from './SearchFrame';
import { SearchHeaderInput } from './SearchHeaderInput';
import { SearchInput } from './SearchInput';
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
import { SearchScopeControl } from './SearchScopeControl';
@@ -69,6 +70,10 @@ export function SearchContainer({
},
{
enableOnFormTags: true,
// Match the logical character typed, not the physical key position, so
// non-QWERTY layouts don't trigger the shortcut by position (e.g. on
// Dvorak the physical "K"/"I" keys produce other characters). RND-11340.
ignoreEventWhen: (e) => e.key.toLowerCase() !== 'k',
}
);
@@ -84,6 +89,9 @@ export function SearchContainer({
},
{
enableOnFormTags: true,
// Match the logical character so Dvorak ⌘-C (physical "I" key) copies
// instead of opening the Assistant. RND-11340.
ignoreEventWhen: (e) => e.key.toLowerCase() !== 'i',
}
);
@@ -214,10 +222,10 @@ export function SearchContainer({
asChild: true,
}}
>
<SearchInput
<SearchHeaderInput
ref={searchInputRef}
aria-activedescendant={searchResultsActiveDescendant}
aria-controls={resultsId}
activeDescendant={searchResultsActiveDescendant}
controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
value={searchValue}
@@ -228,12 +236,7 @@ export function SearchContainer({
resultsCount={results.length}
fetching={fetching}
showAsk={showAsk}
>
<SearchLiveResultsAnnouncer
count={results.length}
showing={Boolean(searchValue) && !fetching}
/>
</SearchInput>
/>
</Popover>
)}
{usesSideSheet ? (
@@ -0,0 +1,75 @@
'use client';
import { tcls } from '@/lib/tailwind';
import React from 'react';
import { SearchInput } from './SearchInput';
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
export interface SearchHeaderInputProps {
activeDescendant?: string;
controls?: string;
className?: string;
fetching?: boolean;
interactive?: boolean;
isOpen?: boolean;
onChange?: (value: string) => void;
onFocus?: () => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
resultsCount?: number;
showAsk?: boolean;
value?: string;
withAI?: boolean;
}
const noop = () => {};
/**
* Header search input visual used by the live site and structure preview.
*/
export const SearchHeaderInput = React.forwardRef<HTMLDivElement, SearchHeaderInputProps>(
function SearchHeaderInput(props, ref) {
const {
activeDescendant,
controls,
className,
fetching = false,
interactive = true,
isOpen = false,
onChange = noop,
onFocus,
onKeyDown,
resultsCount = 0,
showAsk = false,
value = '',
withAI = false,
} = props;
return (
<SearchInput
ref={ref}
aria-activedescendant={activeDescendant}
aria-controls={controls}
onChange={interactive ? onChange : noop}
onKeyDown={interactive && onKeyDown ? onKeyDown : noop}
value={value}
withAI={withAI}
isOpen={interactive && isOpen}
className={tcls(className, !interactive ? 'pointer-events-none select-none' : null)}
onFocus={interactive ? onFocus : undefined}
resultsCount={resultsCount}
fetching={fetching}
showAsk={showAsk}
readOnly={!interactive}
tabIndex={interactive ? undefined : -1}
>
{interactive ? (
<SearchLiveResultsAnnouncer
count={resultsCount}
showing={Boolean(value) && !fetching}
/>
) : null}
</SearchInput>
);
}
);
@@ -21,6 +21,8 @@ interface SearchInputProps {
resultsCount: number;
fetching: boolean;
showAsk: boolean;
readOnly?: boolean;
tabIndex?: number;
}
/**
@@ -1,4 +1,5 @@
export * from './SearchInput';
export * from './SearchHeaderInput';
export * from './SearchFrame';
export * from './SearchLiveResultsAnnouncer';
export * from './SearchContainer';
@@ -82,6 +82,7 @@ export function SiteSectionListItem(props: {
href={section.url}
aria-current={isActive && 'page'}
id={section.id}
data-gb-site-section-id={section.id}
className={tcls(
'group/section-link',
'flex',
@@ -32,11 +32,13 @@ export function SiteSectionTabs(props: {
sections: ClientSiteSections;
className?: string;
children?: React.ReactNode;
disableAnimations?: boolean;
}) {
const {
sections: { list: structure, current: currentSection },
className,
children,
disableAnimations,
} = props;
const containerRef = React.useRef<HTMLDivElement>(null);
@@ -130,14 +132,12 @@ export function SiteSectionTabs(props: {
<NavigationMenu.Content
className={tcls([
'absolute top-0 left-0 w-full md:w-auto',
'data-[motion=from-start]:*:animate-[enterFromLeft_300ms_ease_both] data-[motion=to-end]:*:animate-[exitToRight_300ms_ease_both] data-[motion=to-start]:*:animate-[exitToLeft_300ms_ease_both] motion-safe:data-[motion=from-end]:*:animate-[enterFromRight_300ms_ease_both]',
!disableAnimations
? 'data-[motion=from-start]:*:animate-[enterFromLeft_300ms_ease_both] data-[motion=to-end]:*:animate-[exitToRight_300ms_ease_both] data-[motion=to-start]:*:animate-[exitToLeft_300ms_ease_both] motion-safe:data-[motion=from-end]:*:animate-[enterFromRight_300ms_ease_both]'
: '',
])}
>
<div
className={tcls(
'max-h-[calc(100vh-8rem)] w-full overflow-y-auto overflow-x-hidden'
)}
>
<div className="max-h-[calc(100vh-8rem)] w-full overflow-y-auto overflow-x-hidden circular-corners:rounded-3xl rounded-corners:rounded-xl">
<SectionGroupTileList
items={structureItem.children}
currentSection={currentSection}
@@ -153,6 +153,11 @@ export function SiteSectionTabs(props: {
? structureItem.url
: undefined
}
sectionId={
structureItem.object === 'site-section'
? structureItem.id
: undefined
}
isActive={isActive}
title={title}
icon={icon ? (icon as IconName) : undefined}
@@ -173,10 +178,14 @@ export function SiteSectionTabs(props: {
>
<NavigationMenu.Viewport
className={tcls(
'relative origin-[center_top] overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base shadow-lg',
// Note: this layer is composited (translateZ) and animated. Chromium fails to paint a clipped composited layer's text
// inside an iframe or `overflow-hidden` ancestor. Clipping is done on the inner content wrapper instead.
'relative origin-[center_top] circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base shadow-lg',
'-mt-0.5 h-(--radix-navigation-menu-viewport-height) w-full max-w-full md:w-(--radix-navigation-menu-viewport-width)',
'max-h-[calc(100vh-8rem)] data-[state=closed]:animate-scale-out data-[state=open]:animate-scale-in',
'ease has-[&[data-motion]]:transition-[left,width,height] has-[&[data-motion]]:duration-300'
'max-h-[calc(100vh-8rem)]',
!disableAnimations
? 'ease has-[&[data-motion]]:transition-[left,width,height] has-[&[data-motion]]:duration-300 data-[state=closed]:animate-scale-out data-[state=open]:animate-scale-in'
: ''
)}
style={{
left: viewportLeft,
@@ -227,10 +236,10 @@ function useNavigationMenuViewportOffset(args: {
* A tab representing a section or section group
*/
const SectionTab = React.forwardRef(function SectionTab(
props: { isActive: boolean; title: string; icon?: IconName; url?: string },
props: { isActive: boolean; title: string; icon?: IconName; url?: string; sectionId?: string },
ref: React.Ref<HTMLAnchorElement>
) {
const { isActive, title, icon, url, ...rest } = props;
const { isActive, title, icon, url, sectionId, ...rest } = props;
const isGroup = url === undefined;
return (
<Button
@@ -242,6 +251,7 @@ const SectionTab = React.forwardRef(function SectionTab(
label={title}
trailing={isGroup ? <ToggleChevron /> : null}
active={isActive}
data-gb-site-section-id={sectionId}
className={tcls(
'group/dropdown relative my-1.5 overflow-visible',
isActive
@@ -350,6 +360,7 @@ function SectionGroupTile(props: {
<li className="group/section-tile flex w-full min-w-0 shrink-0 grow md:max-w-[var(--site-section-column-width)]">
<Link
href={url}
data-gb-site-section-id={child.id}
className={tcls(
'grow circular-corners:rounded-2xl rounded-corners:rounded-lg px-2.5 py-1.5 transition-colors',
isActive
@@ -9,12 +9,16 @@ import type React from 'react';
import { Footer } from '@/components/Footer';
import { Header, HeaderLogo } from '@/components/Header';
import { TableOfContents } from '@/components/TableOfContents';
import {
TABLE_OF_CONTENTS_SPACES_DROPDOWN_CLASS,
getTableOfContentsInnerHeaderClassName,
} from '@/components/TableOfContents/styles';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { GITBOOK_APP_URL } from '@/lib/env';
import { tcls } from '@/lib/tailwind';
import { AIChatProvider } from '../AI';
import type { RenderAIMessageOptions } from '../AI';
import { AIChat } from '../AIChat';
import { AIChat, AskAITextSelection } from '../AIChat';
import { AdaptiveVisitorContextProvider } from '../Adaptive';
import { Announcement } from '../Announcement';
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
@@ -127,10 +131,15 @@ export function SpaceLayout(props: SpaceLayoutProps) {
<Announcement context={context} />
<Header withTopHeader={withTopHeader} variants={variants} context={context} />
<NavigationLoader />
{customization.ai?.mode === CustomizationAIMode.Assistant ? <AIChat /> : null}
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
<>
<AIChat />
<AskAITextSelection />
</>
) : null}
{/* Chat panel shifts content left when open */}
<div className="motion-safe:transition-all motion-safe:duration-300 lg:chat-open:mr-80 xl:chat-open:mr-96">
<div className="motion-safe:transition-all motion-safe:duration-300 lg:chat-open:mr-(--ai-chat-width)">
<div
className={tcls(
'flex',
@@ -192,10 +201,9 @@ export function SpaceLayout(props: SpaceLayoutProps) {
innerHeader={
!withTopHeader || variants.generic.length > 1 ? (
<div
className={tcls(
'my-5 sidebar-default:mt-2 flex flex-col gap-2 px-5 empty:hidden',
variants.generic.length > 1 ? '' : 'max-lg:hidden'
)}
className={getTableOfContentsInnerHeaderClassName({
hideOnMobile: variants.generic.length <= 1,
})}
>
{!withTopHeader && (
<div className="flex gap-2 max-lg:hidden">
@@ -220,7 +228,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
context={context}
siteSpace={siteSpace}
siteSpaces={variants.generic}
className="w-full px-3"
className={TABLE_OF_CONTENTS_SPACES_DROPDOWN_CLASS}
/>
) : null}
</div>
@@ -2,10 +2,15 @@ import { languages } from '@/intl/translations';
import type { GitBookSiteContext } from '@/lib/context';
import { getSiteSpaceLanguages, normalizeLanguage } from '@/lib/sites';
type SiteSpaceVariantsContext = Pick<
GitBookSiteContext,
'locale' | 'siteSpace' | 'siteSpaces' | 'visibleSiteSpaces'
>;
/**
* Categorize the variants of the space into generic and translation variants.
*/
export function categorizeVariants(context: GitBookSiteContext) {
export function categorizeVariants(context: SiteSpaceVariantsContext) {
const { siteSpace } = context;
// By default, variants only include visible spaces.
@@ -0,0 +1,603 @@
'use client';
import type { ContentRef, CustomizationContentLink, CustomizationHeaderItem } from '@gitbook/api';
import {
CustomizationAIMode,
CustomizationHeaderPreset,
CustomizationSearchStyle,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import * as React from 'react';
import { SiteSectionList, SiteSectionTabs } from '@/components/SiteSections';
import {
TABLE_OF_CONTENTS_SPACES_DROPDOWN_CLASS,
getTableOfContentsClassName,
getTableOfContentsInnerHeaderClassName,
getTableOfContentsSidebarClassName,
} from '@/components/TableOfContents/styles';
import { Image } from '@/components/utils';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { AIChatButtonView, AIChatIcon, getAIChatName } from '../AIChat';
import { HeaderLayout } from '../Header/HeaderLayout';
import {
HeaderLinkItem,
HeaderLinkMenuItem,
HeaderLinkSubMenu,
SubHeaderLinkItem,
} from '../Header/HeaderLinkClient';
import { HeaderLinkMoreDropdown } from '../Header/HeaderLinkMoreClient';
import {
getHeaderLinkDropdownClassName,
getHeaderLinkMoreDropdownClassName,
} from '../Header/HeaderLinkStyles';
import { HeaderLinks } from '../Header/HeaderLinks';
import {
HEADER_LOGO_CONTAINER_CLASS,
HEADER_LOGO_IMAGE_CLASS,
HEADER_LOGO_IMAGE_SIZES,
HeaderLogoContent,
} from '../Header/HeaderLogoContent';
import {
getSpacesDropdownMenuClassName,
getTranslationsDropdownClassName,
} from '../Header/SpacesDropdownData';
import headerLinksStyles from '../Header/headerLinks.module.css';
import { SearchHeaderInput } from '../Search';
import { CONTAINER_STYLE, CONTENT_STYLE } from '../layout';
import {
Button,
type ButtonProps,
SkeletonHeading,
SkeletonImage,
SkeletonParagraph,
ToggleChevron,
} from '../primitives';
import { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from '../primitives/DropdownMenu';
import {
SOCIAL_PLATFORM_ICONS,
isStructurePreviewMessage,
selectStructurePreviewSection,
} from './state';
import type {
PreviewContentLink,
PreviewDropdownSpace,
PreviewHeaderLink,
StructurePreviewNavigationMessage,
StructurePreviewSnapshot,
} from './types';
const PREVIEW_CONTENT_REF = {
kind: 'url',
url: '#',
} as ContentRef;
const SOCIAL_PLATFORM_LABELS: Partial<
Record<StructurePreviewSnapshot['customization']['socialAccounts'][number]['platform'], string>
> = {
twitter: 'X/Twitter',
instagram: 'Instagram',
facebook: 'Facebook',
linkedin: 'LinkedIn',
github: 'GitHub',
discord: 'Discord',
slack: 'Slack',
youtube: 'YouTube',
tiktok: 'TikTok',
reddit: 'Reddit',
bluesky: 'Bluesky',
mastodon: 'Mastodon',
threads: 'Threads',
medium: 'Medium',
};
export function StructurePreview(props: {
initialSnapshot: StructurePreviewSnapshot;
GITBOOK_APP_URL: string;
}) {
const { initialSnapshot, GITBOOK_APP_URL } = props;
const [snapshot, setSnapshot] = React.useState(initialSnapshot);
React.useEffect(() => {
const handleMessage = (event: MessageEvent<unknown>) => {
if (
event.source !== window.parent ||
event.origin !== GITBOOK_APP_URL ||
!isStructurePreviewMessage(event.data)
) {
return;
}
const message = event.data;
setSnapshot((currentSnapshot) => ({
...currentSnapshot,
...message.payload,
}));
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
const postNavigationChange = (sectionId: string) => {
const message: StructurePreviewNavigationMessage = {
type: 'gitbook.structure.navigate',
payload: { sectionId },
};
window.parent.postMessage(message, GITBOOK_APP_URL);
};
const preventNavigation = (event: React.MouseEvent<HTMLElement>) => {
const target = event.target;
if (!(target instanceof Element)) {
return null;
}
const anchor = target.closest('a');
if (!anchor) {
return null;
}
event.preventDefault();
event.stopPropagation();
return anchor;
};
const fakeSectionNavigation = (event: React.MouseEvent<HTMLElement>) => {
const anchor = preventNavigation(event);
const sectionId = anchor?.getAttribute('data-gb-site-section-id');
if (!sectionId) {
return;
}
setSnapshot((currentSnapshot) => {
const nextSnapshot = selectStructurePreviewSection(currentSnapshot, sectionId);
if (nextSnapshot !== currentSnapshot) {
postNavigationChange(sectionId);
}
return nextSnapshot;
});
};
return (
<div
data-gb-structure-preview
data-viewport-mode="desktop"
onClickCapture={fakeSectionNavigation}
onAuxClickCapture={preventNavigation}
>
<StructurePreviewHeader snapshot={snapshot} />
<div className={tcls('flex gap-8', CONTAINER_STYLE)}>
<StructurePreviewVariantSelector snapshot={snapshot} />
<div className={tcls('my-8 flex min-w-xl grow flex-col gap-8', CONTENT_STYLE)}>
<SkeletonHeading animated={false} />
<SkeletonParagraph lines={4} animated={false} />
<SkeletonParagraph lines={5} animated={false} start={4} />
<SkeletonImage animated={false} />
<SkeletonParagraph lines={3} animated={false} start={9} />
<SkeletonParagraph lines={2} animated={false} start={12} />
</div>
</div>
</div>
);
}
function StructurePreviewHeader(props: { snapshot: StructurePreviewSnapshot }) {
const { snapshot } = props;
const { customization } = snapshot;
const language = useLanguage();
const { variants, sections } = snapshot;
const headerSocialAccounts = customization.socialAccounts;
const previewAssistants = getPreviewAssistants(snapshot, language);
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
const withSections = Boolean(
sections &&
(sections.list.length > 1 ||
sections.list.some((section) => section.object === 'site-section-group'))
);
return (
<HeaderLayout
withTopHeader={withTopHeader}
searchStyle={customization.styling.search}
leading={<StructurePreviewLogo snapshot={snapshot} />}
search={
<>
<StructurePreviewSearch />
{previewAssistants.map((assistant, index) => (
<AIChatButtonView
key={assistant.id}
icon={assistant.icon}
label={assistant.label}
withShortcut={index === 0}
showLabel={
previewAssistants.length === 1 &&
customization.styling.search === CustomizationSearchStyle.Prominent
}
inert
/>
))}
</>
}
links={
customization.header.links.length > 0 ||
headerSocialAccounts.length > 0 ||
(!withSections && variants.translations.length > 1) ? (
<HeaderLinks>
{customization.header.links.map((link, index) => (
<StructurePreviewHeaderLink
key={`${link.title}-${index}`}
link={link}
snapshot={snapshot}
/>
))}
{headerSocialAccounts.length > 0 ? (
<div className="flex items-center gap-1">
{headerSocialAccounts.map((account) => {
const icon = SOCIAL_PLATFORM_ICONS[account.platform];
return icon ? (
<Button
key={`${account.platform}-${account.handle}`}
iconOnly
label={account.platform}
icon={icon}
variant="blank"
size="large"
className="p-2 theme-bold:text-header-link hover:site-header:theme-bold:bg-header-link/3 hover:theme-bold:text-header-link focus-visible:site-header:theme-bold:bg-header-link/3"
/>
) : null;
})}
</div>
) : null}
{customization.header.links.length > 0 ||
headerSocialAccounts.length > 0 ? (
<StructurePreviewMoreMenu
label={tString(language, 'more')}
links={customization.header.links}
socialAccounts={headerSocialAccounts}
snapshot={snapshot}
/>
) : null}
{!withSections && variants.translations.length > 1 ? (
<StructurePreviewTranslationsDropdown
siteSpaces={variants.translations}
className="flex! site-header:theme-bold:text-header-link hover:site-header:theme-bold:bg-header-link/3 focus-visible:site-header:theme-bold:bg-header-link/3 aria-expanded:site-header:theme-bold:bg-header-link/5"
/>
) : null}
</HeaderLinks>
) : null
}
sections={
sections && withSections ? (
//TODO: figure out why enabling animations here break the rendering of what's inside the tabs
<SiteSectionTabs sections={sections} disableAnimations>
{variants.translations.length > 1 ? (
<StructurePreviewTranslationsDropdown
siteSpaces={variants.translations}
className="my-1.5 ml-2 self-start"
/>
) : null}
</SiteSectionTabs>
) : null
}
/>
);
}
function StructurePreviewLogo(props: { snapshot: StructurePreviewSnapshot }) {
const { snapshot } = props;
const { customization } = snapshot;
return (
<div className={HEADER_LOGO_CONTAINER_CLASS}>
<HeaderLogoContent
logo={
customization.header.logo ? (
<StructurePreviewLogoImage logo={customization.header.logo} />
) : null
}
fallbackIcon={<StructurePreviewLogoFallbackIcon snapshot={snapshot} />}
title={snapshot.site.title}
/>
</div>
);
}
function StructurePreviewLogoImage(props: {
logo: NonNullable<StructurePreviewSnapshot['customization']['header']['logo']>;
}) {
const { logo } = props;
return (
<Image
alt="Logo"
resize={false}
sources={{
light: {
src: logo.light,
},
dark: logo.dark
? {
src: logo.dark,
}
: null,
}}
sizes={HEADER_LOGO_IMAGE_SIZES}
preload
style={HEADER_LOGO_IMAGE_CLASS}
/>
);
}
function StructurePreviewLogoFallbackIcon(props: { snapshot: StructurePreviewSnapshot }) {
const { snapshot } = props;
const { customization } = snapshot;
if ('emoji' in customization.favicon && customization.favicon.emoji) {
return <span className="text-xl">{customization.favicon.emoji}</span>;
}
return (
<picture>
<source srcSet={snapshot.icons.large.dark} media="(prefers-color-scheme: dark)" />
<img alt="" src={snapshot.icons.large.light} className="size-8 object-contain" />
</picture>
);
}
function StructurePreviewSearch() {
return <SearchHeaderInput interactive={false} />;
}
function StructurePreviewVariantSelector(props: { snapshot: StructurePreviewSnapshot }) {
const { snapshot } = props;
const { customization, sections, variants } = snapshot;
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
const withSections = Boolean(
sections &&
(sections.list.length > 1 ||
sections.list.some((section) => section.object === 'site-section-group'))
);
const withSidebarHeader = (!withTopHeader && withSections) || variants.generic.length > 1;
return (
<div data-gb-table-of-contents className={tcls(getTableOfContentsClassName(), 'max-w-xs')}>
<div className={getTableOfContentsSidebarClassName()}>
{withSidebarHeader ? (
<div className={getTableOfContentsInnerHeaderClassName()}>
{!withTopHeader && withSections && sections ? (
<SiteSectionList className="hidden lg:block" sections={sections} />
) : null}
{variants.generic.length > 1 ? (
<StructurePreviewSpacesDropdown
title={snapshot.siteSpace.title}
siteSpaces={variants.generic}
className={TABLE_OF_CONTENTS_SPACES_DROPDOWN_CLASS}
/>
) : null}
</div>
) : null}
<div className="ml-5 flex flex-col gap-6">
{Array.from({ length: 4 }).map((_, group) => (
<div className="flex flex-col gap-2" key={group}>
{Array.from({ length: [3, 5, 4, 3][group] ?? 0 }).map((_, index) => (
<SkeletonParagraph
key={index}
start={group * 5 + index}
lines={1}
animated={false}
/>
))}
</div>
))}
</div>
</div>
</div>
);
}
function getPreviewAssistants(
snapshot: StructurePreviewSnapshot,
language: ReturnType<typeof useLanguage>
) {
if (snapshot.customization.ai?.mode !== CustomizationAIMode.Assistant) {
return [];
}
return [
{
id: 'gitbook-assistant',
label: getAIChatName(language, snapshot.customization.trademark.enabled),
icon: (
<AIChatIcon
state="default"
trademark={snapshot.customization.trademark.enabled}
className="size-text-lg"
/>
),
},
];
}
function StructurePreviewHeaderLink(props: {
snapshot: StructurePreviewSnapshot;
link: PreviewHeaderLink;
}) {
const { snapshot, link } = props;
const headerLink = toCustomizationHeaderItem(link);
return (
<HeaderLinkItem
link={headerLink}
locale={snapshot.locale}
headerPreset={snapshot.customization.header.preset}
href={link.hasTarget ? '#' : undefined}
hasTarget={link.hasTarget}
dropdownClassName={getHeaderLinkDropdownClassName(
snapshot.customization.styling.search
)}
>
{link.links.map((subLink, index) => (
<SubHeaderLinkItem
key={index}
link={toCustomizationContentLink(subLink)}
locale={snapshot.locale}
/>
))}
</HeaderLinkItem>
);
}
function StructurePreviewMoreMenu(props: {
snapshot: StructurePreviewSnapshot;
links: PreviewHeaderLink[];
socialAccounts: StructurePreviewSnapshot['customization']['socialAccounts'];
label: React.ReactNode;
}) {
const { snapshot, links, socialAccounts, label } = props;
return (
<div className={`${headerLinksStyles.linkEllipsis} z-20 items-center`}>
<HeaderLinkMoreDropdown
label={label}
dropdownClassName={getHeaderLinkMoreDropdownClassName(
snapshot.customization.styling.search
)}
>
{links.map((link, index) => (
<StructurePreviewMenuLink key={index} link={link} snapshot={snapshot} />
))}
{socialAccounts.length > 0 && <DropdownMenuSeparator />}
{socialAccounts.map((account) => (
<StructurePreviewSocialAccountLink
key={`${account.platform}-${account.handle}`}
account={account}
/>
))}
</HeaderLinkMoreDropdown>
</div>
);
}
function StructurePreviewMenuLink(props: {
snapshot: StructurePreviewSnapshot;
link: PreviewHeaderLink | PreviewContentLink;
}) {
const { snapshot, link } = props;
return isPreviewHeaderLink(link) && link.links.length > 0 ? (
<HeaderLinkSubMenu link={toCustomizationHeaderItem(link)} locale={snapshot.locale}>
{link.links.map((subLink, index) => (
<StructurePreviewMenuLink key={index} link={subLink} snapshot={snapshot} />
))}
</HeaderLinkSubMenu>
) : (
<HeaderLinkMenuItem
link={
isPreviewHeaderLink(link)
? toCustomizationHeaderItem(link)
: toCustomizationContentLink(link)
}
locale={snapshot.locale}
/>
);
}
function StructurePreviewSocialAccountLink(props: {
account: StructurePreviewSnapshot['customization']['socialAccounts'][number];
}) {
const { account } = props;
const icon = SOCIAL_PLATFORM_ICONS[account.platform];
if (!icon) {
return null;
}
return (
<DropdownMenuItem leadingIcon={icon}>
{SOCIAL_PLATFORM_LABELS[account.platform] ?? account.platform}
</DropdownMenuItem>
);
}
function isPreviewHeaderLink(
link: PreviewHeaderLink | PreviewContentLink
): link is PreviewHeaderLink {
return 'links' in link;
}
function toCustomizationHeaderItem(link: PreviewHeaderLink): CustomizationHeaderItem {
return {
title: link.title,
style: link.style,
to: link.hasTarget ? PREVIEW_CONTENT_REF : null,
links: link.links.map(toCustomizationContentLink),
} as CustomizationHeaderItem;
}
function toCustomizationContentLink(link: PreviewContentLink): CustomizationContentLink {
return {
title: link.title,
to: link.hasTarget ? PREVIEW_CONTENT_REF : undefined,
} as CustomizationContentLink;
}
function StructurePreviewTranslationsDropdown(props: {
siteSpaces: PreviewDropdownSpace[];
className?: string;
}) {
const { siteSpaces, className } = props;
const title = siteSpaces.find((siteSpace) => siteSpace.isActive)?.title ?? siteSpaces[0]?.title;
if (!title) {
return null;
}
return (
<StructurePreviewSpacesDropdown
title={title}
siteSpaces={siteSpaces}
icon="globe"
variant="blank"
className={getTranslationsDropdownClassName({ title, className })}
/>
);
}
function StructurePreviewSpacesDropdown(props: {
title: string;
siteSpaces: PreviewDropdownSpace[];
className?: ButtonProps['className'];
icon?: IconName;
variant?: ButtonProps['variant'];
}) {
const { title, siteSpaces, className, icon, variant = 'secondary' } = props;
return (
<DropdownMenu
className={getSpacesDropdownMenuClassName()}
button={
<Button
icon={icon}
data-testid="space-dropdown-button"
size="small"
variant={variant}
trailing={<ToggleChevron />}
className={tcls('bg-tint-base', className)}
>
<span className="button-content">{title}</span>
</Button>
}
>
{siteSpaces.map((siteSpace) => (
<DropdownMenuItem key={siteSpace.id} active={siteSpace.isActive}>
{siteSpace.title}
</DropdownMenuItem>
))}
</DropdownMenu>
);
}
@@ -0,0 +1,2 @@
export * from './StructurePreview';
export * from './types';
@@ -0,0 +1,313 @@
import { describe, expect, it } from 'bun:test';
import { getStructurePreviewSnapshot } from '@/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/snapshot';
import { languages } from '@/intl/translations';
import type { GitBookSiteContext } from '@/lib/context';
import { defaultCustomization, findSectionInGroup } from '@/lib/utils';
import { SiteSocialAccountPlatform, TranslationLanguage } from '@gitbook/api';
import {
isStructurePreviewMessage,
isStructurePreviewNavigationMessage,
selectStructurePreviewSection,
} from './state';
function createContext(overrides: Partial<GitBookSiteContext> = {}): GitBookSiteContext {
const siteSpace = {
id: 'site-space-1',
title: 'Docs',
path: '',
default: true,
hidden: false,
urls: {},
space: {
id: 'space-1',
revision: 'revision-1',
language: 'en',
},
};
return {
site: {
id: 'site-1',
title: 'Acme Docs',
},
locale: undefined,
customization: defaultCustomization(),
siteSpace,
siteSpaces: [siteSpace],
visibleSiteSpaces: [siteSpace],
sections: null,
visibleSections: null,
linker: {
toPathInSpace: (path: string) => `/space/${path}`,
},
...overrides,
} as GitBookSiteContext;
}
describe('structure preview state', () => {
it('validates partial preview update messages without revision data', () => {
const snapshot = getStructurePreviewSnapshot(createContext());
const update = {
sections: snapshot.sections,
variants: snapshot.variants,
siteSpace: snapshot.siteSpace,
};
expect(
isStructurePreviewMessage({
type: 'gitbook.structure.update',
payload: update,
})
).toBe(true);
expect(
isStructurePreviewMessage({
type: 'gitbook.structure.update',
payload: { sections: snapshot.sections },
})
).toBe(true);
expect('revision' in snapshot).toBe(false);
expect('structure' in snapshot).toBe(false);
expect('siteSpaces' in snapshot).toBe(false);
expect('visibleSiteSpaces' in snapshot).toBe(false);
expect(isStructurePreviewMessage({ type: 'gitbook.structure.update' })).toBe(false);
expect(
isStructurePreviewMessage({
type: 'gitbook.structure.update',
payload: snapshot,
})
).toBe(false);
expect(
isStructurePreviewMessage({
type: 'gitbook.structure.update',
payload: { site: snapshot.site },
})
).toBe(false);
expect(isStructurePreviewMessage({ type: 'other', payload: snapshot })).toBe(false);
expect(
isStructurePreviewMessage({
type: 'gitbook.structure.navigate',
payload: { sectionId: 'section-1' },
})
).toBe(false);
});
it('validates preview navigation messages', () => {
expect(
isStructurePreviewNavigationMessage({
type: 'gitbook.structure.navigate',
payload: { sectionId: 'section-1' },
})
).toBe(true);
expect(isStructurePreviewNavigationMessage({ type: 'gitbook.structure.navigate' })).toBe(
false
);
expect(
isStructurePreviewNavigationMessage({
type: 'gitbook.structure.navigate',
payload: { sectionId: 1 },
})
).toBe(false);
expect(
isStructurePreviewNavigationMessage({
type: 'gitbook.structure.update',
payload: { sectionId: 'section-1' },
})
).toBe(false);
});
it('stores pre-encoded section structures with inert URLs', () => {
const section = {
object: 'site-section',
id: 'section-1',
title: 'Guides',
localizedTitle: { fr: 'Guides FR' },
description: 'Learn',
path: 'guides',
default: true,
siteSpaces: [],
urls: {},
};
const snapshot = getStructurePreviewSnapshot(
createContext({
locale: TranslationLanguage.Fr,
sections: {
list: [
{
object: 'site-section-group',
id: 'group-1',
title: 'Products',
children: [section],
},
],
current: section,
},
} as unknown as Partial<GitBookSiteContext>)
);
expect(snapshot.sections?.current.title).toBe('Guides FR');
expect(snapshot.sections?.current.url).toBe('#');
expect(snapshot.sections?.list[0]?.object).toBe('site-section-group');
});
it('stores precomputed variant groups with slim translation titles', () => {
const currentSiteSpace = {
id: 'v15-it',
title: 'v15',
path: '',
default: false,
hidden: false,
urls: {},
space: {
id: 'space-v15-it',
revision: 'revision-v15-it',
language: TranslationLanguage.It,
},
};
const siteSpaces = [
{ id: 'v20-en', title: 'v20', language: TranslationLanguage.En },
{ id: 'v20-fr', title: 'v20', language: TranslationLanguage.Fr },
{ id: 'v20-it', title: 'v20', language: TranslationLanguage.It },
{ id: 'v15-en', title: 'v15', language: TranslationLanguage.En },
{ id: 'v15-fr', title: 'v15', language: TranslationLanguage.Fr },
currentSiteSpace,
].map((siteSpace) =>
'space' in siteSpace
? siteSpace
: {
id: siteSpace.id,
title: siteSpace.title,
path: '',
default: false,
hidden: false,
urls: {},
space: {
id: `space-${siteSpace.id}`,
revision: `revision-${siteSpace.id}`,
language: siteSpace.language,
},
}
);
const snapshot = getStructurePreviewSnapshot(
createContext({
locale: TranslationLanguage.It,
siteSpace: currentSiteSpace,
siteSpaces,
visibleSiteSpaces: siteSpaces,
} as Partial<GitBookSiteContext>)
);
expect(snapshot.variants.generic.map((space) => space.id)).toEqual(['v20-it', 'v15-it']);
expect(
snapshot.variants.translations.map((space) => ({
id: space.id,
title: space.title,
isActive: space.isActive,
}))
).toEqual([
{ id: 'v15-en', title: languages.en.language, isActive: false },
{ id: 'v15-fr', title: languages.fr.language, isActive: false },
{ id: 'v15-it', title: languages.it.language, isActive: true },
]);
});
it('stores only header-visible social account fields', () => {
const customization = defaultCustomization();
customization.socialAccounts = [
{
platform: SiteSocialAccountPlatform.Github,
handle: 'gitbook',
display: { header: true, footer: true },
},
{
platform: SiteSocialAccountPlatform.Discord,
handle: 'hidden',
display: { header: false, footer: true },
},
];
const snapshot = getStructurePreviewSnapshot(createContext({ customization }));
expect(snapshot.customization.socialAccounts).toEqual([
{ platform: SiteSocialAccountPlatform.Github, handle: 'gitbook' },
]);
});
it('selects a top-level section in the local snapshot', () => {
const snapshot = createSnapshotWithSections();
const nextSnapshot = selectStructurePreviewSection(snapshot, 'reference');
expect(nextSnapshot).not.toBe(snapshot);
expect(nextSnapshot.sections?.current.id).toBe('reference');
expect(nextSnapshot.sections?.current.title).toBe('Reference');
});
it('selects a nested section in the local snapshot', () => {
const snapshot = createSnapshotWithSections();
const nextSnapshot = selectStructurePreviewSection(snapshot, 'api');
const currentSection = nextSnapshot.sections?.current;
const group = nextSnapshot.sections?.list[1];
expect(currentSection?.id).toBe('api');
expect(group?.object).toBe('site-section-group');
if (!currentSection || group?.object !== 'site-section-group') {
throw new Error('Expected a nested section inside a section group');
}
expect(findSectionInGroup(group, currentSection.id)?.id).toBe('api');
});
it('keeps the current snapshot when selecting an unknown section', () => {
const snapshot = createSnapshotWithSections();
const nextSnapshot = selectStructurePreviewSection(snapshot, 'missing');
expect(nextSnapshot).toBe(snapshot);
expect(nextSnapshot.sections?.current.id).toBe('intro');
});
it('keeps snapshots without sections unchanged', () => {
const snapshot = getStructurePreviewSnapshot(createContext());
const nextSnapshot = selectStructurePreviewSection(snapshot, 'reference');
expect(nextSnapshot).toBe(snapshot);
expect(nextSnapshot.sections).toBeNull();
});
});
function createSnapshotWithSections() {
const intro = createSection('intro', 'Intro');
const reference = createSection('reference', 'Reference');
const api = createSection('api', 'API');
return getStructurePreviewSnapshot(
createContext({
sections: {
list: [
intro,
{
object: 'site-section-group',
id: 'developers',
title: 'Developers',
children: [api],
},
reference,
],
current: intro,
},
} as unknown as Partial<GitBookSiteContext>)
);
}
function createSection(id: string, title: string) {
return {
object: 'site-section',
id,
title,
description: '',
path: id,
default: false,
siteSpaces: [],
urls: {},
};
}
@@ -0,0 +1,216 @@
import type { SiteSocialAccountPlatform } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import type { ClientSiteSection, ClientSiteSectionGroup } from '../SiteSections';
import type {
StructurePreviewMessage,
StructurePreviewNavigationMessage,
StructurePreviewSnapshot,
StructurePreviewUpdate,
} from './types';
const STRUCTURE_PREVIEW_UPDATE_KEYS = ['sections', 'siteSpace', 'variants'] as const;
export function isStructurePreviewMessage(value: unknown): value is StructurePreviewMessage {
if (!value || typeof value !== 'object') {
return false;
}
const message = value as Partial<StructurePreviewMessage>;
return message.type === 'gitbook.structure.update' && isStructurePreviewUpdate(message.payload);
}
export function isStructurePreviewNavigationMessage(
value: unknown
): value is StructurePreviewNavigationMessage {
if (!value || typeof value !== 'object') {
return false;
}
const message = value as Partial<StructurePreviewNavigationMessage>;
const payload = message.payload as Partial<StructurePreviewNavigationMessage['payload']>;
return message.type === 'gitbook.structure.navigate' && typeof payload?.sectionId === 'string';
}
export function isStructurePreviewUpdate(value: unknown): value is StructurePreviewUpdate {
if (!value || typeof value !== 'object') {
return false;
}
const update = value as Partial<StructurePreviewUpdate>;
const keys = Object.keys(update);
if (
keys.length === 0 ||
keys.some(
(key) =>
!STRUCTURE_PREVIEW_UPDATE_KEYS.includes(
key as (typeof STRUCTURE_PREVIEW_UPDATE_KEYS)[number]
)
)
) {
return false;
}
return (
(!('sections' in update) || isStructurePreviewSections(update.sections)) &&
(!('siteSpace' in update) || isStructurePreviewSiteSpace(update.siteSpace)) &&
(!('variants' in update) || isStructurePreviewVariants(update.variants))
);
}
export function selectStructurePreviewSection(
snapshot: StructurePreviewSnapshot,
sectionId: string
): StructurePreviewSnapshot {
const sections = snapshot.sections;
if (!sections || sections.current.id === sectionId) {
return snapshot;
}
const selectedSection = findPreviewSection(sections.list, sectionId);
if (!selectedSection) {
return snapshot;
}
return {
...snapshot,
sections: {
...sections,
current: selectedSection,
},
};
}
function findPreviewSection(
items: (ClientSiteSection | ClientSiteSectionGroup)[],
sectionId: string
): ClientSiteSection | null {
for (const item of items) {
if (item.object === 'site-section') {
if (item.id === sectionId) {
return item;
}
continue;
}
const childSection = findPreviewSection(item.children, sectionId);
if (childSection) {
return childSection;
}
}
return null;
}
function isStructurePreviewSections(value: unknown): value is StructurePreviewSnapshot['sections'] {
if (value === null) {
return true;
}
if (!value || typeof value !== 'object') {
return false;
}
const sections = value as Partial<NonNullable<StructurePreviewSnapshot['sections']>>;
return (
Array.isArray(sections.list) &&
sections.list.every(isStructurePreviewSectionItem) &&
isStructurePreviewSection(sections.current)
);
}
function isStructurePreviewSectionItem(
value: unknown
): value is ClientSiteSection | ClientSiteSectionGroup {
return isStructurePreviewSection(value) || isStructurePreviewSectionGroup(value);
}
function isStructurePreviewSection(value: unknown): value is ClientSiteSection {
if (!value || typeof value !== 'object') {
return false;
}
const section = value as Partial<ClientSiteSection>;
return (
section.object === 'site-section' &&
typeof section.id === 'string' &&
typeof section.title === 'string' &&
typeof section.url === 'string'
);
}
function isStructurePreviewSectionGroup(value: unknown): value is ClientSiteSectionGroup {
if (!value || typeof value !== 'object') {
return false;
}
const group = value as Partial<ClientSiteSectionGroup>;
return (
group.object === 'site-section-group' &&
typeof group.id === 'string' &&
typeof group.title === 'string' &&
Array.isArray(group.children) &&
group.children.every(isStructurePreviewSectionItem)
);
}
function isStructurePreviewSiteSpace(
value: unknown
): value is StructurePreviewSnapshot['siteSpace'] {
if (!value || typeof value !== 'object') {
return false;
}
const siteSpace = value as Partial<StructurePreviewSnapshot['siteSpace']>;
return (
typeof siteSpace.id === 'string' &&
typeof siteSpace.title === 'string' &&
typeof siteSpace.path === 'string'
);
}
function isStructurePreviewVariants(value: unknown): value is StructurePreviewSnapshot['variants'] {
if (!value || typeof value !== 'object') {
return false;
}
const variants = value as Partial<StructurePreviewSnapshot['variants']>;
return (
Array.isArray(variants.generic) &&
variants.generic.every(isPreviewDropdownSpace) &&
Array.isArray(variants.translations) &&
variants.translations.every(isPreviewDropdownSpace)
);
}
function isPreviewDropdownSpace(
value: unknown
): value is StructurePreviewSnapshot['variants']['generic'][number] {
if (!value || typeof value !== 'object') {
return false;
}
const siteSpace = value as Partial<StructurePreviewSnapshot['variants']['generic'][number]>;
return (
typeof siteSpace.id === 'string' &&
typeof siteSpace.title === 'string' &&
typeof siteSpace.isActive === 'boolean'
);
}
export const SOCIAL_PLATFORM_ICONS: Partial<Record<SiteSocialAccountPlatform, IconName>> = {
twitter: 'x-twitter',
instagram: 'instagram',
facebook: 'facebook',
linkedin: 'linkedin',
github: 'github',
discord: 'discord',
slack: 'slack',
youtube: 'youtube',
tiktok: 'tiktok',
reddit: 'reddit',
bluesky: 'bluesky',
mastodon: 'mastodon',
threads: 'threads',
medium: 'medium',
};
@@ -0,0 +1,93 @@
import type {
CustomizationAIMode,
CustomizationHeaderItem,
CustomizationHeaderPreset,
CustomizationSearchStyle,
SiteSocialAccountPlatform,
TranslationLanguage,
} from '@gitbook/api';
import type { ClientSiteSections } from '@/components/SiteSections';
export type PreviewHeaderLink = {
title: string;
style?: CustomizationHeaderItem['style'];
hasTarget: boolean;
links: PreviewContentLink[];
};
export type PreviewContentLink = {
title: string;
hasTarget: boolean;
};
export type PreviewDropdownSpace = {
id: string;
title: string;
isActive: boolean;
};
export type StructurePreviewSnapshot = {
site: {
title: string;
};
locale?: TranslationLanguage;
customization: {
styling: {
search: CustomizationSearchStyle;
};
favicon: {
emoji?: string;
};
header: {
preset: CustomizationHeaderPreset;
logo?: {
light: string;
dark?: string;
};
links: PreviewHeaderLink[];
};
ai: {
mode: CustomizationAIMode;
};
trademark: {
enabled: boolean;
};
socialAccounts: {
platform: SiteSocialAccountPlatform;
handle: string;
}[];
};
siteSpace: {
id: string;
title: string;
path: string;
};
variants: {
generic: PreviewDropdownSpace[];
translations: PreviewDropdownSpace[];
};
sections: ClientSiteSections | null;
icons: {
large: {
light: string;
dark: string;
};
};
};
export type StructurePreviewUpdate = Partial<
Pick<StructurePreviewSnapshot, 'sections' | 'siteSpace' | 'variants'>
>;
export type StructurePreviewMessage = {
type: 'gitbook.structure.update';
payload: StructurePreviewUpdate;
};
export type StructurePreviewNavigationMessage = {
type: 'gitbook.structure.navigate';
payload: {
sectionId: string;
};
};
@@ -2,6 +2,7 @@
import { tcls } from '@/lib/tailwind';
import { SiteInsightsLinkPosition } from '@gitbook/api';
import { OpenAPIMethodBadge } from '@gitbook/react-openapi';
import { Tag } from '../Tag';
import { PagesList } from './PagesList';
import { TOCPageIcon } from './TOCPageIcon';
@@ -41,7 +42,18 @@ export function PageDocumentItem(props: { page: ClientTOCPageDocument }) {
icon={<TOCPageIcon page={page} />}
tag={page.primaryTag ? <Tag tag={page.primaryTag} /> : null}
>
{page.title}
{page.openAPIOperation ? (
<span className="flex h-[1lh] w-9 shrink-0 self-baseline">
<OpenAPIMethodBadge
method={page.openAPIOperation.method}
short
size="small"
/>
</span>
) : null}
<span className={page.openAPIOperation?.deprecated ? 'line-through' : undefined}>
{page.title}
</span>
</ToggleableLinkItem>
</li>
);
@@ -9,6 +9,7 @@ import { PagesList } from './PagesList';
import { TableOfContentsScript } from './TableOfContentsScript';
import { Trademark } from './Trademark';
import { encodeClientTableOfContents } from './encodeClientTableOfContents';
import { getTableOfContentsClassName, getTableOfContentsSidebarClassName } from './styles';
/**
* Sidebar container, responsible for setting the right dimensions and position for the sidebar.
@@ -34,90 +35,11 @@ export async function TableOfContents(props: {
toggleClass="navigation-open"
withOverlay={true}
withCloseButton={true}
className={tcls(
'group/table-of-contents',
'text-sm',
'grow-0',
'shrink-0',
'w-4/5',
'md:w-1/2',
'lg:w-72',
'max-lg:not-sidebar-filled:bg-tint-base',
'max-lg:not-sidebar-filled:border-r',
'border-tint-subtle',
'lg:flex!',
'lg:animate-none!',
'lg:sticky',
'lg:mr-12',
'lg:z-0',
'layout-wide:no-sidebar:lg:fixed',
'layout-wide:no-sidebar:lg:max-3xl:w-12',
'layout-wide:no-sidebar:lg:left-5',
'layout-wide:no-sidebar:lg:z-30',
'layout-default:no-sidebar:lg:max-xl:fixed',
'layout-default:no-sidebar:lg:max-xl:w-12',
'layout-default:no-sidebar:lg:max-xl:left-5',
'layout-default:no-sidebar:lg:max-xl:z-30',
// Server-side static positioning
'lg:top-0',
'lg:h-screen',
'lg:announcement:h-[calc(100vh-4.25rem)]',
// With header
'lg:site-header:top-16',
'lg:site-header:h-[calc(100vh-4rem)]',
'lg:announcement:site-header:h-[calc(100vh-4rem-4.25rem)]',
'lg:site-header-sections:top-27',
'lg:site-header-sections:h-[calc(100vh-6.75rem)]',
'lg:site-header-sections:announcement:h-[calc(100vh-6.75rem-4.25rem)]',
// Client-side dynamic positioning (CSS vars applied by script)
'lg:[html[style*="--toc-top-offset"]_&]:top-(--toc-top-offset)!',
'lg:[html[style*="--toc-height"]_&]:h-(--toc-height)!',
'lg:page-no-toc:[html[style*="--outline-top-offset"]_&]:top-(--outline-top-offset)!',
'lg:page-no-toc:[html[style*="--outline-height"]_&]:h-(--outline-height)!',
'pt-6 pb-4',
'supports-[-webkit-touch-callout]:pb-[env(safe-area-inset-bottom)]', // Override bottom padding on iOS since we have a transparent bottom bar
'lg:max-3xl:has-sidebar:sidebar-filled:layout-default:pr-6',
'max-lg:pl-8',
'flex',
'flex-col',
'min-h-0',
'gap-4',
className
)}
className={getTableOfContentsClassName(className)}
>
{header}
<div // The actual sidebar, either shown with a filled bg or transparent.
className={tcls(
'-ms-5', // By default we shift the sidebar to the left to compensate for the PagesList padding.
'layout-wide:no-sidebar:ms-0 layout-default:no-sidebar:lg:max-xl:ms-0',
'relative flex min-h-0 grow flex-col border-tint-subtle',
'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'[html.sidebar-filled.theme-gradient_&]:border',
'max-lg:sidebar-filled:border',
'lg:page-no-toc:bg-transparent!',
'lg:page-no-toc:border-none!',
'sidebar-filled:rounded-2xl',
'straight-corners:rounded-none',
'[html.sidebar-filled.circular-corners_&]:layout-wide:rounded-4xl'
)}
className={getTableOfContentsSidebarClassName()}
>
{innerHeader}
<ScrollContainer
@@ -1,4 +1,5 @@
import type { GitBookSiteContext } from '@/lib/context';
import { getOpenAPIOperationPageProps } from '@/lib/openapi/computedSourceProps';
import { getPagePaths, hasPageVisibleDescendant } from '@/lib/pages';
import { resolveContentRef } from '@/lib/references';
import { getRevisionTags, resolveTag } from '@/lib/tags';
@@ -26,6 +27,7 @@ export type ClientTOCPageDocument = {
pathnames: string[];
descendants?: ClientTOCPage[];
primaryTag?: RevisionTag;
openAPIOperation?: { method: string; deprecated: boolean };
};
export type ClientTOCPageGroup = {
@@ -83,6 +85,7 @@ export async function encodeClientTableOfContents(
pathnames: getPagePaths(rootPages, page),
descendants,
primaryTag,
openAPIOperation: getOpenAPIOperationPageProps(page),
type: 'document',
})
);
@@ -1,9 +1,11 @@
import { type ClassValue, tcls } from '@/lib/tailwind';
export const ToCItemBaseStyles = [
'flex flex-row justify-start items-center gap-3',
'circular-corners:rounded-2xl rounded-md straight-corners:rounded-none p-1.5 pl-3',
'focus-visible:-outline-offset-2',
'before:contents[] before:-left-px before:absolute before:inset-y-0',
'sidebar-list-line:rounded-l-none! sidebar-list-line:before:w-px [&+div_a]:sidebar-list-default:rounded-l-none [&+div_a]:pl-5 [&+div_a]:sidebar-list-default:before:w-px',
'sidebar-list-line:rounded-l-none! sidebar-list-line:before:w-px [&+div_a]:sidebar-list-default:rounded-l-none has-[.openapi-method]:pl-3 [&+div_a]:pl-5 [&+div_a]:sidebar-list-default:before:w-px',
];
export const ToCLinkItemStyles = [
@@ -46,3 +48,106 @@ export const ToCLinkItemActiveStyles = [
'contrast-more:ring-primary',
'contrast-more:hover:ring-primary-hover',
];
export function getTableOfContentsClassName(className?: ClassValue) {
return tcls(
'group/table-of-contents',
'text-sm',
'grow-0',
'shrink-0',
'w-4/5',
'md:w-1/2',
'lg:w-72',
'max-lg:not-sidebar-filled:bg-tint-base',
'max-lg:not-sidebar-filled:border-r',
'border-tint-subtle',
'lg:flex!',
'lg:animate-none!',
'lg:sticky',
'lg:mr-12',
'lg:z-0',
'layout-wide:no-sidebar:lg:fixed',
'layout-wide:no-sidebar:lg:max-3xl:w-12',
'layout-wide:no-sidebar:lg:left-5',
'layout-wide:no-sidebar:lg:z-30',
'layout-default:no-sidebar:lg:max-xl:fixed',
'layout-default:no-sidebar:lg:max-xl:w-12',
'layout-default:no-sidebar:lg:max-xl:left-5',
'layout-default:no-sidebar:lg:z-30',
// Server-side static positioning
'lg:top-0',
'lg:h-screen',
'lg:announcement:h-[calc(100vh-4.25rem)]',
// With header
'lg:site-header:top-16',
'lg:site-header:h-[calc(100vh-4rem)]',
'lg:announcement:site-header:h-[calc(100vh-4rem-4.25rem)]',
'lg:site-header-sections:top-27',
'lg:site-header-sections:h-[calc(100vh-6.75rem)]',
'lg:site-header-sections:announcement:h-[calc(100vh-6.75rem-4.25rem)]',
// Client-side dynamic positioning (CSS vars applied by script)
'lg:[html[style*="--toc-top-offset"]_&]:top-(--toc-top-offset)!',
'lg:[html[style*="--toc-height"]_&]:h-(--toc-height)!',
'lg:page-no-toc:[html[style*="--outline-top-offset"]_&]:top-(--outline-top-offset)!',
'lg:page-no-toc:[html[style*="--outline-height"]_&]:h-(--outline-height)!',
'pt-6 pb-4',
'supports-[-webkit-touch-callout]:pb-[env(safe-area-inset-bottom)]',
'lg:max-3xl:has-sidebar:sidebar-filled:layout-default:pr-6',
'max-lg:pl-8',
'flex',
'flex-col',
'min-h-0',
'gap-4',
className
);
}
export function getTableOfContentsSidebarClassName(className?: ClassValue) {
return tcls(
'-ms-5',
'layout-wide:no-sidebar:ms-0 layout-default:no-sidebar:lg:max-xl:ms-0',
'relative flex min-h-0 grow flex-col border-tint-subtle',
'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'[html.sidebar-filled.theme-gradient_&]:border',
'max-lg:sidebar-filled:border',
'lg:page-no-toc:bg-transparent!',
'lg:page-no-toc:border-none!',
'sidebar-filled:rounded-2xl',
'straight-corners:rounded-none',
'[html.sidebar-filled.circular-corners_&]:layout-wide:rounded-4xl',
className
);
}
export function getTableOfContentsInnerHeaderClassName(props?: {
hideOnMobile?: boolean;
className?: ClassValue;
}) {
const { hideOnMobile = false, className } = props ?? {};
return tcls(
'my-5 sidebar-default:mt-2 flex flex-col gap-2 px-5 empty:hidden',
hideOnMobile ? 'max-lg:hidden' : '',
className
);
}
export const TABLE_OF_CONTENTS_SPACES_DROPDOWN_CLASS = 'w-full px-3';
@@ -58,8 +58,10 @@ export const variantClasses = {
],
secondary: [
'bg-tint',
'depth-flat:bg-transparent',
'text-tint',
'hover:bg-tint-hover',
'hover:not-disabled:depth-flat:bg-tint-hover',
'hover:not-disabled:text-tint',
'contrast-more:bg-tint-subtle',
'disabled:bg-transparent',

Some files were not shown because too many files have changed in this diff Show More