mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-03 20:20:35 +00:00
Merge branch 'main' into stevenh/bump-shiki
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add fullwidth page option
|
||||
@@ -932,6 +932,22 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'Page actions',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Without page actions',
|
||||
url: getCustomizationURL({
|
||||
pageActions: {
|
||||
markdown: false,
|
||||
externalAI: false,
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Ads',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/components/AIActions/AIActions';
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { DropdownMenu } from '@/components/primitives/DropdownMenu';
|
||||
import type { SiteCustomizationSettings } from '@gitbook/api';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useRef } from 'react';
|
||||
@@ -16,10 +17,7 @@ interface AIActionsDropdownProps {
|
||||
markdownPageUrl: string;
|
||||
withAIChat?: boolean;
|
||||
trademark: boolean;
|
||||
/**
|
||||
* Whether to include the "Open in LLM" entries in the dropdown menu.
|
||||
*/
|
||||
withLLMActions?: boolean;
|
||||
actions: SiteCustomizationSettings['pageActions'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +57,7 @@ export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
||||
* The content of the dropdown menu.
|
||||
*/
|
||||
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
|
||||
const { markdownPageUrl, withAIChat, trademark, withLLMActions } = props;
|
||||
const { markdownPageUrl, withAIChat, trademark, actions } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -67,14 +65,18 @@ function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
|
||||
<OpenDocsAssistant trademark={trademark} type="dropdown-menu-item" />
|
||||
) : null}
|
||||
|
||||
<CopyMarkdown
|
||||
isDefaultAction={!withAIChat}
|
||||
markdownPageUrl={markdownPageUrl}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
|
||||
{actions.markdown ? (
|
||||
<>
|
||||
<CopyMarkdown
|
||||
isDefaultAction={!withAIChat}
|
||||
markdownPageUrl={markdownPageUrl}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{withLLMActions ? (
|
||||
{actions.externalAI ? (
|
||||
<>
|
||||
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="dropdown-menu-item" />
|
||||
<OpenInLLM provider="claude" url={markdownPageUrl} type="dropdown-menu-item" />
|
||||
@@ -88,17 +90,27 @@ function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
|
||||
* A default action shown as a quick-access button beside the dropdown menu
|
||||
*/
|
||||
function DefaultAction(props: AIActionsDropdownProps) {
|
||||
const { markdownPageUrl, withAIChat, trademark } = props;
|
||||
const { markdownPageUrl, withAIChat, trademark, actions } = props;
|
||||
|
||||
if (withAIChat) {
|
||||
return <OpenDocsAssistant trademark={trademark} type="button" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyMarkdown
|
||||
isDefaultAction={!withAIChat}
|
||||
markdownPageUrl={markdownPageUrl}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
if (actions.markdown) {
|
||||
return (
|
||||
<CopyMarkdown
|
||||
isDefaultAction={!withAIChat}
|
||||
markdownPageUrl={markdownPageUrl}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (actions.externalAI) {
|
||||
return (
|
||||
<>
|
||||
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="button" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,23 @@ type UnwrappedBlocksProps<TBlock extends DocumentBlock> = DocumentContextProps &
|
||||
isOffscreen?: boolean;
|
||||
};
|
||||
|
||||
/* Blocks that can be full width are automatically expanded on full-width pages.
|
||||
* Ideally we'd rely on the block type to determine if it can be full width, but
|
||||
* the block's `fullWidth` property does not differentiate between `undefined` and `false`.
|
||||
* So instead we hardcode a list of blocks that can be full width. */
|
||||
const FULL_WIDTH_BLOCKS = [
|
||||
'table',
|
||||
'tabs',
|
||||
'integration',
|
||||
'openapi',
|
||||
'images',
|
||||
'embed',
|
||||
'columns',
|
||||
'code',
|
||||
'content-ref',
|
||||
'hint',
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders a list of blocks without a wrapper element.
|
||||
*/
|
||||
@@ -68,10 +85,11 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
|
||||
key={node.key || `${node.type}-${index}`}
|
||||
block={node}
|
||||
style={[
|
||||
'mx-auto w-full decoration-primary/6',
|
||||
'mx-auto page-width-wide:mx-0 w-full decoration-primary/6',
|
||||
node.data && 'fullWidth' in node.data && node.data.fullWidth
|
||||
? 'max-w-screen-2xl'
|
||||
: 'page-full-width:ml-0 max-w-3xl',
|
||||
: 'max-w-3xl',
|
||||
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-screen-2xl',
|
||||
blockStyle,
|
||||
]}
|
||||
isEstimatedOffscreen={isOffscreen}
|
||||
|
||||
@@ -7,5 +7,5 @@ import type { BlockProps } from './Block';
|
||||
export function Divider(props: BlockProps<DocumentBlockDivider>) {
|
||||
const { style } = props;
|
||||
|
||||
return <hr className={tcls(style, 'page-full-width:max-w-full border-tint-subtle')} />;
|
||||
return <hr className={tcls(style, 'page-width-wide:max-w-full border-tint-subtle')} />;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
|
||||
'flex',
|
||||
'items-baseline',
|
||||
'scroll-m-12',
|
||||
getTextAlignment(block.data.align),
|
||||
hashLinkButtonWrapperStyles,
|
||||
style,
|
||||
textStyle.marginTop
|
||||
|
||||
@@ -36,16 +36,16 @@ export function Footer(props: { context: GitBookSiteContext }) {
|
||||
<div className={tcls(CONTAINER_STYLE, 'px-4', 'py-8', 'lg:py-12', 'mx-auto')}>
|
||||
<div
|
||||
className={tcls(
|
||||
'lg:!max-w-none mx-auto grid max-w-3xl site-full-width:max-w-screen-2xl justify-between gap-12',
|
||||
'lg:!max-w-none mx-auto grid max-w-3xl site-width-wide:max-w-screen-2xl justify-between gap-12',
|
||||
'grid-cols-[auto_auto]',
|
||||
'lg:grid-cols-[18rem_minmax(auto,_48rem)_auto]',
|
||||
'xl:grid-cols-[18rem_minmax(auto,_48rem)_14rem]',
|
||||
'site-full-width:lg:grid-cols-[18rem_minmax(auto,_80rem)_auto]',
|
||||
'site-full-width:xl:grid-cols-[18rem_minmax(auto,_80rem)_14rem]',
|
||||
'site-width-wide:lg:grid-cols-[18rem_minmax(auto,_80rem)_auto]',
|
||||
'site-width-wide:xl:grid-cols-[18rem_minmax(auto,_80rem)_14rem]',
|
||||
'page-no-toc:lg:grid-cols-[minmax(auto,_48rem)_auto]',
|
||||
'page-no-toc:xl:grid-cols-[14rem_minmax(auto,_48rem)_14rem]',
|
||||
'[body:has(.site-full-width,.page-no-toc)_&]:lg:grid-cols-[minmax(auto,_90rem)_auto]',
|
||||
'[body:has(.site-full-width,.page-no-toc)_&]:xl:grid-cols-[14rem_minmax(auto,_90rem)_14rem]'
|
||||
'[body:has(.site-width-wide,.page-no-toc)_&]:lg:grid-cols-[minmax(auto,_90rem)_auto]',
|
||||
'[body:has(.site-width-wide,.page-no-toc)_&]:xl:grid-cols-[14rem_minmax(auto,_90rem)_14rem]'
|
||||
)}
|
||||
>
|
||||
{
|
||||
@@ -106,7 +106,7 @@ export function Footer(props: { context: GitBookSiteContext }) {
|
||||
'col-span-2 page-has-toc:lg:col-span-1 page-has-toc:lg:col-start-2 page-no-toc:xl:col-span-1 page-no-toc:xl:col-start-2'
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex max-w-3xl site-full-width:max-w-screen-2xl flex-col gap-10 sm:flex-row sm:gap-6">
|
||||
<div className="mx-auto flex max-w-3xl site-width-wide:max-w-screen-2xl flex-col gap-10 sm:flex-row sm:gap-6">
|
||||
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
|
||||
(column, columnIndex) => (
|
||||
<div
|
||||
|
||||
@@ -55,7 +55,7 @@ export function PageAside(props: {
|
||||
'chat-open:xl:max-3xl:max-w-0',
|
||||
'chat-open:xl:max-3xl:ml-0',
|
||||
|
||||
'motion-safe:transition-all motion-safe:duration-300',
|
||||
'motion-safe:xl:transition-all motion-safe:xl:duration-300',
|
||||
'motion-safe:[transition-behavior:allow-discrete]',
|
||||
|
||||
'flex-col',
|
||||
|
||||
@@ -40,8 +40,8 @@ export function PageBody(props: {
|
||||
LINK_PREVIEW_MAX_COUNT
|
||||
)
|
||||
: false;
|
||||
const pageFullWidth = page.id === 'wtthNFMqmEQmnt5LKR0q';
|
||||
const asFullWidth = pageFullWidth || contentFullWidth;
|
||||
const pageWidthWide = page.layout.width === 'wide';
|
||||
const siteWidthWide = pageWidthWide || contentFullWidth;
|
||||
const language = getSpaceLanguage(customization);
|
||||
const updatedAt = page.updatedAt ?? page.createdAt;
|
||||
|
||||
@@ -53,12 +53,12 @@ export function PageBody(props: {
|
||||
'mx-auto max-w-screen-2xl py-8',
|
||||
// Allow words to break if they are too long.
|
||||
'break-anywhere',
|
||||
pageFullWidth ? 'page-full-width 2xl:px-8' : 'page-default-width',
|
||||
asFullWidth ? 'site-full-width' : 'site-default-width',
|
||||
pageWidthWide ? 'page-width-wide 2xl:px-8' : 'page-width-default',
|
||||
siteWidthWide ? 'site-width-wide' : 'site-width-default',
|
||||
page.layout.tableOfContents ? 'page-has-toc' : 'page-no-toc'
|
||||
)}
|
||||
>
|
||||
<PreservePageLayout asFullWidth={asFullWidth} />
|
||||
<PreservePageLayout siteWidthWide={siteWidthWide} />
|
||||
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
|
||||
<PageCover as="hero" page={page} cover={page.cover} context={context} />
|
||||
) : null}
|
||||
@@ -93,19 +93,28 @@ export function PageBody(props: {
|
||||
<PageFooterNavigation context={context} page={page} />
|
||||
) : null}
|
||||
|
||||
<div className="mx-auto mt-6 page-api-block:ml-0 flex max-w-3xl page-full-width:max-w-screen-2xl flex-row flex-wrap items-center gap-4 text-tint contrast-more:text-tint-strong">
|
||||
{updatedAt ? (
|
||||
<p className="mr-auto text-sm">
|
||||
{t(language, 'page_last_modified', <DateRelative value={updatedAt} />)}
|
||||
</p>
|
||||
) : null}
|
||||
{withPageFeedback ? (
|
||||
<PageFeedbackForm
|
||||
className={page.layout.outline ? 'xl:hidden' : ''}
|
||||
pageId={page.id}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{
|
||||
// TODO: after 25/07/2025, we can chage it to a true check as the cache will be updated
|
||||
page.layout.metadata !== false ? (
|
||||
<div className="mx-auto mt-6 page-api-block:ml-0 flex max-w-3xl page-full-width:max-w-screen-2xl flex-row flex-wrap items-center gap-4 text-tint contrast-more:text-tint-strong">
|
||||
{updatedAt ? (
|
||||
<p className="mr-auto text-sm ">
|
||||
{t(
|
||||
language,
|
||||
'page_last_modified',
|
||||
<DateRelative value={updatedAt} />
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{withPageFeedback ? (
|
||||
<PageFeedbackForm
|
||||
className={page.layout.outline ? 'xl:hidden' : ''}
|
||||
pageId={page.id}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
</main>
|
||||
|
||||
<TrackPageViewEvent />
|
||||
|
||||
@@ -71,11 +71,13 @@ export async function PageBodyBlankslate(props: {
|
||||
className={tcls(
|
||||
'grid',
|
||||
'max-w-3xl',
|
||||
'page-width-wide:max-w-screen-2xl',
|
||||
'w-full',
|
||||
'mx-auto',
|
||||
'gap-4',
|
||||
'grid-cols-1',
|
||||
'sm:grid-cols-2'
|
||||
'sm:grid-cols-2',
|
||||
'page-width-wide:md:grid-cols-3'
|
||||
)}
|
||||
>
|
||||
{pageElements}
|
||||
|
||||
@@ -95,7 +95,7 @@ export async function PageCover(props: {
|
||||
: [
|
||||
'sm:mx-auto',
|
||||
'max-w-3xl ',
|
||||
'page-full-width:max-w-screen-2xl',
|
||||
'page-width-wide:max-w-screen-2xl',
|
||||
'sm:rounded-md',
|
||||
'mb-8',
|
||||
]
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function PageFooterNavigation(props: {
|
||||
'mt-6',
|
||||
'gap-2',
|
||||
'max-w-3xl',
|
||||
'page-full-width:max-w-screen-2xl',
|
||||
'page-width-wide:max-w-screen-2xl',
|
||||
'mx-auto',
|
||||
'text-tint'
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import type { AncestorRevisionPage } from '@/lib/pages';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { type RevisionPageDocument, SiteVisibility } from '@gitbook/api';
|
||||
import type { RevisionPageDocument } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { PageIcon } from '../PageIcon';
|
||||
import { StyledLink } from '../primitives';
|
||||
@@ -22,11 +22,17 @@ export async function PageHeader(props: {
|
||||
|
||||
const withAIChat = isAIChatEnabled(context);
|
||||
|
||||
const pageActions = context.customization.pageActions ?? {
|
||||
// TODO: After 25/07/2025, we can remove this default values as the cache will be updated
|
||||
markdown: true,
|
||||
externalAI: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className={tcls(
|
||||
'max-w-3xl',
|
||||
'page-full-width:max-w-screen-2xl',
|
||||
'page-width-wide:max-w-screen-2xl',
|
||||
'mx-auto',
|
||||
'mb-6',
|
||||
'space-y-3',
|
||||
@@ -34,7 +40,9 @@ export async function PageHeader(props: {
|
||||
'page-api-block:max-w-full'
|
||||
)}
|
||||
>
|
||||
{page.layout.tableOfContents ? (
|
||||
{page.layout.tableOfContents &&
|
||||
// Show page actions if *any* of the actions are enabled
|
||||
(withAIChat || pageActions.markdown || pageActions.externalAI) ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'float-right mb-2 ml-4',
|
||||
@@ -45,7 +53,7 @@ export async function PageHeader(props: {
|
||||
markdownPageUrl={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`}
|
||||
withAIChat={withAIChat}
|
||||
trademark={context.customization.trademark.enabled}
|
||||
withLLMActions={context.site.visibility === SiteVisibility.Public}
|
||||
actions={pageActions}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -6,14 +6,14 @@ import * as React from 'react';
|
||||
* This approach is needed as page layout (full width block) is done using CSS (`body:has(.full-width)`),
|
||||
* which becomes false while transitioning between the 2 page states:
|
||||
*
|
||||
* 1. Page 1 with full width block: `body:has(.site-full-width)` is true
|
||||
* 2. Loading skeleton while transitioning to page 2: `body:has(.site-full-width)` is false
|
||||
* 3. Page 2 with full width block: `body:has(.site-full-width)` is true
|
||||
* 1. Page 1 with full width block: `body:has(.site-width-wide)` is true
|
||||
* 2. Loading skeleton while transitioning to page 2: `body:has(.site-width-wide)` is false
|
||||
* 3. Page 2 with full width block: `body:has(.site-width-wide)` is true
|
||||
*
|
||||
* This component ensures that the layout is preserved while transitioning between the 2 page states (in step 2).
|
||||
*/
|
||||
export function PreservePageLayout(props: { asFullWidth: boolean }) {
|
||||
const { asFullWidth } = props;
|
||||
export function PreservePageLayout(props: { siteWidthWide: boolean }) {
|
||||
const { siteWidthWide } = props;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
// We use the header as it's an element preserved between page transitions
|
||||
@@ -23,12 +23,12 @@ export function PreservePageLayout(props: { asFullWidth: boolean }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (asFullWidth) {
|
||||
header.classList.add('site-full-width');
|
||||
if (siteWidthWide) {
|
||||
header.classList.add('site-width-wide');
|
||||
} else {
|
||||
header.classList.remove('site-full-width');
|
||||
header.classList.remove('site-width-wide');
|
||||
}
|
||||
}, [asFullWidth]);
|
||||
}, [siteWidthWide]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function SitePageSkeleton() {
|
||||
'lg:items-start'
|
||||
)}
|
||||
>
|
||||
<div className={tcls('flex-1', 'max-w-3xl', 'mx-auto', 'site-full-width:mx-0')}>
|
||||
<div className={tcls('flex-1', 'max-w-3xl', 'mx-auto', 'site-width-wide:mx-0')}>
|
||||
<SkeletonHeading style={tcls('mb-8')} />
|
||||
<SkeletonParagraph style={tcls('mb-4')} />
|
||||
</div>
|
||||
|
||||
@@ -97,7 +97,7 @@ export function SpaceLayout(props: {
|
||||
'flex-col',
|
||||
'lg:flex-row',
|
||||
CONTAINER_STYLE,
|
||||
'site-full-width:max-w-full',
|
||||
'site-width-wide:max-w-full',
|
||||
|
||||
// Ensure the footer is display below the viewport even if the content is not enough
|
||||
withFooter && 'min-h-[calc(100vh-64px)]',
|
||||
|
||||
@@ -14,7 +14,6 @@ export const CONTAINER_STYLE: ClassValue = [
|
||||
'md:px-8',
|
||||
'max-w-screen-2xl',
|
||||
'mx-auto',
|
||||
// 'site-full-width:max-w-full',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -564,9 +564,9 @@ const config: Config = {
|
||||
/**
|
||||
* Variant when the page contains a block that will be rendered in full-width mode.
|
||||
*/
|
||||
addVariant('site-full-width', 'body:has(.site-full-width) &');
|
||||
addVariant('site-default-width', 'body:has(.site-default-width) &');
|
||||
addVariant('page-full-width', 'body:has(.page-full-width) &');
|
||||
addVariant('site-width-wide', 'body:has(.site-width-wide) &');
|
||||
addVariant('site-width-default', 'body:has(.site-width-default) &');
|
||||
addVariant('page-width-wide', 'body:has(.page-width-wide) &');
|
||||
|
||||
/**
|
||||
* Variant when the page is configured to hide the table of content.
|
||||
|
||||
Reference in New Issue
Block a user