mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-03 12:10:34 +00:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb46b5b38b | |||
| 8a9f163d31 | |||
| 7c365f9162 | |||
| 10a7095182 | |||
| 614a50373a | |||
| d172684b88 | |||
| 7646cb301c | |||
| d2ce6adeb6 | |||
| 937c8b8eb2 | |||
| 36dbdb48cc | |||
| 2dc76a2c3c | |||
| 0edc378c57 | |||
| 2eb313194a | |||
| 882dfcda83 | |||
| 3ff88ba22f | |||
| 51bd768042 | |||
| daadd91eba | |||
| f4469b2df3 | |||
| 91b325ca08 | |||
| 1616028426 | |||
| 6c15d8314a | |||
| 63e4968987 | |||
| 6c967c9bcc | |||
| 6eb3d3b4e5 | |||
| 2ba90833a3 | |||
| c44d822fe1 | |||
| b0553d5fc8 | |||
| af3cda1c53 | |||
| a06da70f5f | |||
| a0ad403a91 | |||
| 0ca49523a2 | |||
| 76550415ac | |||
| 53ec8b9f29 | |||
| 6f63c00300 | |||
| c334ea2748 | |||
| d75cdf1334 | |||
| cb649c55cb | |||
| 205c512cb6 | |||
| 334a31d7fc | |||
| f157cfc9a7 | |||
| 509389c7e5 | |||
| 3485c0192b | |||
| 27d9ad6fe4 | |||
| 96602eb552 | |||
| 17d092acdc | |||
| 107018f570 | |||
| 3e152b0662 | |||
| 6a8720894d | |||
| 8a5ed60c26 | |||
| 52fc156b06 | |||
| 46091b65d1 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Drive page actions ordering and default action from the new `pageActions.items` list, including the reorderable assistant action
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.27.0",
|
||||
"version": "0.27.1",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+9
-2
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
+23
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+42
@@ -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>
|
||||
);
|
||||
}
|
||||
+184
@@ -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: '#',
|
||||
};
|
||||
}
|
||||
+24
@@ -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 });
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user