Compare commits

..

14 Commits

Author SHA1 Message Date
Nolann Biron 40d86bae70 revert to 3 2026-01-22 00:59:22 +01:00
Nolann Biron e1abd11014 test with max_dpr = 2 2026-01-21 21:34:03 +01:00
Nolann Biron 64697f4c6a Limit MAX_DPR to 3 in Image component
Reduces the maximum device pixel ratio (DPR) from 4 to 3 in the Image component to align with the configuration in routes/image.ts.
2026-01-21 21:25:19 +01:00
Nolann B. f647af2003 Prevent duplicate Content-Type headers in OpenAPI code samples (#3924) 2026-01-21 14:51:46 +01:00
Greg Bergé 96e24a1f84 Fix JWT token usage in GitBook embed SDK (#3922) 2026-01-21 12:05:14 +01:00
Viktor Renkema 2a0f37f4b3 Always display page actions even if there is no toc/title/description (#3921) 2026-01-21 10:54:42 +01:00
Zeno Kapitein 17d935dd4c Upgrade API to 0.158.0 (#3918) 2026-01-20 17:55:47 +01:00
Greg Bergé df07fbe242 Remove development from package.exports before publishing (#3917) 2026-01-20 17:22:24 +01:00
Zeno Kapitein 1e53376151 Add SideSheet component, refactor TOC and AIChat to use it (#3835) 2026-01-19 21:19:54 +01:00
Samy Pessé f294818775 Serve markdown version of the page depending on the accept type (#3914) 2026-01-19 15:11:02 -05:00
Greg Bergé eed386a133 Fix custom tools usage with GitBook Embed SDK (#3913) 2026-01-19 13:14:23 +01:00
Zeno Kapitein e172b25ba8 Grow InlineActionButton to available space on line (#3912) 2026-01-19 12:55:03 +01:00
Zeno Kapitein f7f6f9d06d Set <html lang> based on translated language (#3909) 2026-01-16 12:00:39 +01:00
Claire Chabas 9e01676fe9 Remove grid from framed images (#3907) 2026-01-15 14:06:26 +01:00
66 changed files with 1152 additions and 731 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add sidesheet component, use it for TOC and AIChat
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Set <html lang> based on translated language
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/embed": patch
---
Fix signed JWT token usage
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Serve a markdown version of the page when the "Accept" header contains "text/markdown".
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Grow InlineActionButton to take available width on line
+7 -2
View File
@@ -56,6 +56,7 @@
},
"devDependencies": {
"@types/react": "catalog:",
"bun-types": "catalog:",
"react": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
@@ -156,6 +157,7 @@
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "15.4.10",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
@@ -195,6 +197,7 @@
"@types/jsontoxml": "^1.0.5",
"@types/jsonwebtoken": "^9.0.6",
"@types/mdast": "^4.0.4",
"@types/negotiator": "^0.6.4",
"@types/node": "^20",
"@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4",
@@ -345,7 +348,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.155.0",
"@gitbook/api": "0.158.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -726,7 +729,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.1.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" } }, "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA=="],
"@gitbook/api": ["@gitbook/api@0.155.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-g4UfJRnej/GZ9pORHcS9mV0kKiJLRvhMlGOeOoEVU/LSIz6s35VDqauSBs7rH00AOaMABDg7uEko8tUSA4aaww=="],
"@gitbook/api": ["@gitbook/api@0.158.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-oWrUY0gQ4rVKyNciHaXQrvi+EoiLCWHhAD0QyAhsHJp9+ndaLRMV6WfK2KbiGQmH8kiWwwBmwmQfMkZFmm1eyQ=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -1532,6 +1535,8 @@
"@types/ms": ["@types/ms@0.7.34", "", {}, "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g=="],
"@types/negotiator": ["@types/negotiator@0.6.4", "", {}, "sha512-elf6BsTq+AkyNsb2h5cGNst2Mc7dPliVoAPm1fXglC/BM3f2pFA40BaSSv3E5lyHteEawVKLP+8TwiY1DMNb3A=="],
"@types/node": ["@types/node@20.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA=="],
"@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="],
+1 -1
View File
@@ -41,7 +41,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.155.0",
"@gitbook/api": "0.158.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+2
View File
@@ -23,6 +23,7 @@
"react": "*"
},
"devDependencies": {
"bun-types": "catalog:",
"@types/react": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
@@ -33,6 +34,7 @@
"build-lib": "tsdown",
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
"clean": "rm -rf ./dist",
"unit": "bun test",
"typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'bun:test';
import { createGitBook } from './createGitBook';
describe('createGitBook.getFrameURL', () => {
it('builds the embed URL when the site URL has no trailing slash', () => {
const client = createGitBook({ siteURL: 'https://example.com/docs' });
const url = new URL(client.getFrameURL({}));
expect(url.origin).toBe('https://example.com');
expect(url.pathname).toBe('/docs/~gitbook/embed');
expect(url.searchParams.toString()).toBe('');
});
it('handles a trailing slash and adds visitor parameters', () => {
const client = createGitBook({ siteURL: 'https://example.com/docs/' });
const url = new URL(
client.getFrameURL({
visitor: {
token: 'signed-token',
unsignedClaims: {
role: 'editor',
count: 3,
enabled: false,
},
},
})
);
expect(url.pathname).toBe('/docs/~gitbook/embed');
expect(url.searchParams.get('jwt_token')).toBe('signed-token');
expect(url.searchParams.get('visitor.role')).toBe('editor');
expect(url.searchParams.get('visitor.count')).toBe('3');
expect(url.searchParams.get('visitor.enabled')).toBe('false');
});
});
+1 -1
View File
@@ -43,7 +43,7 @@ export function createGitBook(options: CreateGitBookOptions) {
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed`;
if (frameOptions.visitor?.token) {
url.searchParams.set('token', frameOptions.visitor.token);
url.searchParams.set('jwt_token', frameOptions.visitor.token);
}
if (frameOptions.visitor?.unsignedClaims) {
+3 -1
View File
@@ -15,7 +15,9 @@
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"types": []
"types": [
"bun-types" // add Bun global
]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
-64
View File
@@ -677,70 +677,6 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Markdown page',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: 'Text page',
url: 'text-page.md',
screenshot: false,
run: async (_page, response) => {
expect(response?.status()).toBe(200);
expect(response?.headers()['content-type']).toContain('text/markdown');
},
},
],
},
{
name: 'llms.txt',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: 'llms.txt',
url: 'llms.txt',
screenshot: false,
run: async (_page, response) => {
expect(response?.status()).toBe(200);
expect(response?.headers()['content-type']).toContain('text/markdown');
},
},
],
},
{
name: 'llms-full.txt',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: 'llms-full.txt',
url: 'llms-full.txt',
screenshot: false,
run: async (_page, response) => {
expect(response?.status()).toBe(200);
expect(response?.headers()['content-type']).toContain('text/markdown');
},
},
],
},
{
name: '[page].md',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: 'blocks.md',
url: 'blocks.md',
screenshot: false,
run: async (_page, response) => {
expect(response?.status()).toBe(200);
expect(response?.headers()['content-type']).toContain('text/markdown');
},
},
],
},
{
name: 'Site subdirectory (proxy)',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
+1
View File
@@ -355,6 +355,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
url: 'https://www.gitbook.com/privacy',
},
socialPreview: {},
socialAccounts: [],
};
const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial));
+3 -1
View File
@@ -77,7 +77,8 @@
"usehooks-ts": "catalog:",
"warn-once": "^0.1.1",
"zod": "^3",
"zustand": "^5.0.3"
"zustand": "^5.0.3",
"negotiator": "^1.0.0"
},
"devDependencies": {
"@argos-ci/playwright": "^5.0.9",
@@ -94,6 +95,7 @@
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/rison": "^0.0.9",
"@types/negotiator": "^0.6.4",
"bun-types": "catalog:",
"deepmerge": "^4.3.1",
"env-cmd": "^10.1.0",
@@ -23,7 +23,8 @@ export default async function SiteDynamicLayout({
return (
<CustomizationRootLayout
className="site-background"
htmlClassName="sheet-open:gutter-stable"
bodyClassName="site-background"
forcedTheme={forcedTheme}
context={context}
>
@@ -19,7 +19,11 @@ export default async function SiteStaticLayout({
const withTracking = shouldTrackEvents();
return (
<CustomizationRootLayout className="site-background" context={context}>
<CustomizationRootLayout
htmlClassName="sheet-open:gutter-stable"
bodyClassName="site-background"
context={context}
>
<SiteLayout
context={context}
withTracking={withTracking}
@@ -27,6 +27,7 @@ import { useTrackEvent } from '../Insights';
import { useNow } from '../hooks';
import { Button } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
import { AIChatControlButton } from './AIChatControlButton';
import { AIChatIcon } from './AIChatIcon';
import { AIChatInput } from './AIChatInput';
@@ -69,15 +70,22 @@ export function AIChat() {
}, [chat.opened, trackEvent]);
return (
<div
<SideSheet
side="right"
open={chat.opened}
onOpenChange={(open) => {
if (open) {
chatController.open();
} else {
chatController.close();
}
}}
withOverlay={true}
className={tcls(
'ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl scroll-mt-36 px-4 py-4 transition-[width,opacity,margin,display] transition-discrete duration-300 sm:px-6 lg:fixed lg:w-80 lg:p-0 xl:w-96',
chat.opened
? 'lg:starting:ml-0 lg:starting:w-0 lg:starting:opacity-0'
: 'hidden lg:ml-0 lg:w-0! lg:opacity-0'
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
)}
>
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base transition-all duration-300 max-lg:circular-corners:rounded-3xl max-lg:rounded-corners:rounded-md max-lg:border lg:w-80 xl:w-96">
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
<EmbeddableFrameMain data-testid="ai-chat">
<EmbeddableFrameHeader>
<AIChatDynamicIcon trademark={config.trademark} />
@@ -107,7 +115,7 @@ export function AIChat() {
</EmbeddableFrameBody>
</EmbeddableFrameMain>
</EmbeddableFrame>
</div>
</SideSheet>
);
}
@@ -218,8 +226,8 @@ export function AIChatBody(props: {
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
contentClassName="p-4 gutter-stable flex flex-col gap-4"
orientation="vertical"
fadeEdges={['leading']}
active={`message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
trailing={{ fade: false, button: true }}
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
>
{isEmpty ? (
<div className="flex grow flex-col">
@@ -41,7 +41,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
aria-describedby={describedById}
className={tcls(
'fixed',
'z-10',
'z-50',
'bg-tint-base',
'rounded-sm',
'straight-corners:rounded-none',
@@ -52,9 +52,9 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'depth-flat:shadow-none',
'p-4',
'pr-8',
'bottom-4',
'right-4',
'left-16',
'bottom-[max(env(safe-area-inset-bottom),1rem)]',
'right-[max(env(safe-area-inset-right),1rem)]',
'left-[max(env(safe-area-inset-left),4rem)]',
'max-w-md',
'text-balance',
'sm:left-auto',
@@ -96,36 +96,6 @@ async function ImageBlock(props: {
return (
<div className={tcls('relative', 'overflow-hidden')}>
{/* Frame grid */}
{withFrame && (
<div
className={tcls(
'absolute',
'-top-0.5',
'-left-0.5',
'right-px',
'bottom-px',
'opacity-40',
'dark:opacity-[0.1]',
'bg-[length:24px_24px,24px_24px]',
'bg-[linear-gradient(to_right,_rgb(234,235,238)_1px,_transparent_1px),linear-gradient(to_bottom,_rgb(234,235,238)_1px,_transparent_1px)]',
'dark:bg-[linear-gradient(to_right,_rgb(122,128,139)_1px,_transparent_1px),linear-gradient(to_bottom,_rgb(122,128,139)_1px,_transparent_1px)]',
'bg-repeat'
)}
/>
)}
{/* Shadow overlay */}
{withFrame && (
<div
className={tcls(
'pointer-events-none absolute inset-0 rounded-2xl',
'shadow-[inset_0_0_10px_10px_rgba(255,255,255,0.9)]',
'dark:shadow-[inset_0_0_10px_10px_rgb(29,29,29)]'
)}
/>
)}
<Caption {...props} fit>
<Image
alt={block.data.alt ?? ''}
@@ -43,7 +43,7 @@ export function InlineActionButton(
inline
label={buttonProps.label as string}
sizing="medium"
className="inline-flex max-w-full leading-normal [transition-property:translate,opacity,box-shadow,background,border]"
className="inline-flex max-w-full grow"
submitButton={{
label: tString(language, action === 'ask' ? 'send' : 'search'),
}}
@@ -14,7 +14,6 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
variant: inline.data.kind,
icon: inline.data.icon as IconName | undefined,
size: 'medium',
className: 'leading-normal',
};
const ButtonImplementation = () => {
@@ -37,9 +36,7 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
const inlineElement = (
// Set the leading to have some vertical space between adjacent buttons
<span className="inline-button leading-12 [&:has(+.inline-button)]:mr-2">
<ButtonImplementation />
</span>
<ButtonImplementation />
);
return inlineElement;
@@ -9,8 +9,12 @@ import { getTextAlignment } from './utils';
export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
const { block, style, ...contextProps } = props;
// InlineActionButtons use flex-grow to take the available width. This requires the parent to be a flex container.
const inlineButtonStyle =
'has-[.button,input]:flex has-[.button,input]:flex-wrap has-[.button,input]:gap-2 has-[.button,input]:items-center';
return (
<p className={tcls(style, getTextAlignment(block.data?.align))}>
<p className={tcls(inlineButtonStyle, style, getTextAlignment(block.data?.align))}>
<Inlines {...contextProps} nodes={block.nodes} ancestorInlines={[]} />
</p>
);
@@ -9,11 +9,11 @@ export function getTextAlignment(textAlignment: TextAlignment | undefined): Clas
switch (textAlignment) {
case undefined:
case TextAlignment.Start:
return ['text-start', 'self-start'];
return ['text-start', 'self-start', 'justify-start'];
case TextAlignment.Center:
return ['text-center', 'self-center'];
return ['text-center', 'self-center', 'justify-center'];
case TextAlignment.End:
return ['text-end', 'self-end'];
return ['text-end', 'self-end', 'justify-end'];
default:
return nullIfNever(textAlignment);
}
@@ -79,9 +79,10 @@ export async function EmbeddableDocsPage(
orientation="vertical"
className="not-hydrated:animate-blur-in-slow"
contentClassName="p-4"
fadeEdges={context.sections ? [] : ['leading']}
leading={{ fade: !context.sections, button: true }}
trailing={{ fade: false, button: true }}
>
<TableOfContents className="pt-0" context={context} />
<TableOfContents context={context} withTrademark={false} />
<PageBody
context={context}
page={page}
@@ -2,7 +2,7 @@
import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitbook/embed';
import { createChannel } from 'bidc';
import React from 'react';
import React, { useEffect, useRef } from 'react';
import { useAI, useAIChatController } from '@/components/AI';
import { CustomizationAIMode } from '@gitbook/api';
@@ -19,6 +19,12 @@ const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() =
tools: [],
}));
// biome-ignore lint/suspicious/noExplicitAny: expected
function log(...data: any[]) {
// biome-ignore lint/suspicious/noConsole: expected
console.log(...data);
}
/**
* Expose the API to communicate with the parent window.
*/
@@ -30,24 +36,32 @@ export function EmbeddableIframeAPI(props: {
const router = useRouter();
const chatController = useAIChatController();
// Live ref to avoid adding them as dependencies
const refs = useRef({ router, chatController, baseURL });
useEffect(() => {
refs.current = { router, chatController, baseURL };
});
React.useEffect(() => {
return chatController.on('open', () => {
const { baseURL, router } = refs.current;
router.push(`${baseURL}/assistant`);
});
}, [router, baseURL, chatController]);
}, [chatController]);
React.useEffect(() => {
if (window.parent === window) {
return;
}
console.log('[gitbook] create channel with parent window');
log('[gitbook] create channel with parent window');
const channel = createChannel();
channel.receive((payload) => {
const { baseURL, router, chatController } = refs.current;
const message = payload as ParentToFrameMessage;
console.log('[gitbook] received message', message);
log('[gitbook] received message', message);
switch (message.type) {
case 'clearChat': {
@@ -79,10 +93,10 @@ export function EmbeddableIframeAPI(props: {
});
return () => {
console.log('[gitbook] cleanup');
log('[gitbook] cleanup');
channel.cleanup();
};
}, [chatController, router, baseURL]);
}, []);
return null;
}
@@ -9,7 +9,7 @@ import type { VisitorAuthClaims } from '@/lib/adaptive';
import type { GitBookSiteContext } from '@/lib/context';
import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import { SpaceLayoutServerContext } from '../SpaceLayout';
import { TrademarkLink } from '../TableOfContents/Trademark';
import { Trademark } from '../TableOfContents/Trademark';
import { NavigationLoader } from '../primitives/NavigationLoader';
import { EmbeddableIframeAPI } from './EmbeddableIframeAPI';
@@ -57,8 +57,8 @@ export async function EmbeddableRootLayout({
<div className="fixed inset-0 flex flex-col">
{children}
{context.customization.trademark.enabled ? (
<TrademarkLink
className="border-tint-solid/3 border-t bg-tint-solid/1 px-4 py-2.5 text-tint/8 ring-0"
<Trademark
className="rounded-none! border-x-0 border-t border-b-0 bg-tint-solid/1 depth-flat:bg-tint-solid/1 px-4 py-2.5 text-tint/8"
context={context}
placement={SiteInsightsTrademarkPlacement.Embed}
/>
@@ -42,6 +42,7 @@ export function Header(props: {
`h-[${HEADER_HEIGHT_DESKTOP}px]`,
'sticky',
'top-0',
'pt-[env(safe-area-inset-top)]',
'z-30',
'w-full',
'flex-none',
@@ -1,16 +1,13 @@
'use client';
import { usePathname } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { useEffect } from 'react';
import { tString, useLanguage } from '@/intl/client';
import { useScrollListener } from '../hooks/useScrollListener';
import { Button, type ButtonProps } from '../primitives';
const globalClassName = 'navigation-open';
const SCROLL_DISTANCE = 320;
/**
* Button to show/hide the table of content on mobile.
*/
@@ -18,25 +15,6 @@ export function HeaderMobileMenu(props: ButtonProps) {
const language = useLanguage();
const pathname = usePathname();
const hasScrollRef = useRef(false);
const [isOpen, setIsOpen] = useState(false);
const toggleNavigation = () => {
if (!hasScrollRef.current && document.body.classList.contains(globalClassName)) {
document.body.classList.remove(globalClassName);
setIsOpen(false);
} else {
document.body.classList.add(globalClassName);
window.scrollTo(0, 0);
setIsOpen(true);
}
};
const windowRef = useRef(typeof window === 'undefined' ? null : window);
useScrollListener(() => {
hasScrollRef.current = window.scrollY >= SCROLL_DISTANCE;
}, windowRef);
// Close the navigation when navigating to a page
useEffect(() => {
@@ -50,8 +28,10 @@ export function HeaderMobileMenu(props: ButtonProps) {
iconOnly
variant="blank"
label={tString(language, 'table_of_contents_button_label')}
onClick={toggleNavigation}
active={isOpen}
onClick={() => {
document.body.classList.toggle(globalClassName);
}}
// Since the button is hidden behind the TOC after toggling, we don't need to keep track of its active state.
{...props}
/>
);
@@ -17,7 +17,7 @@ interface VariantSpace {
*/
function useVariantSpaceHref(variantSpace: VariantSpace, currentSpacePath: string, active = false) {
const currentPathname = useCurrentPagePath();
const { metaLinks, currentPage } = useCurrentPageMetadata();
const { metaLinks } = useCurrentPageMetadata();
// We first check if there is an alternate link for the variant space in the current page metadata.
const pageHasAlternateForVariant = metaLinks?.alternates.find(
@@ -27,17 +27,6 @@ function useVariantSpaceHref(variantSpace: VariantSpace, currentSpacePath: strin
return pageHasAlternateForVariant.href;
}
const firstAlternate = metaLinks?.alternates[0];
const computed = firstAlternate
? {
pageID: firstAlternate.pageID,
spaceID: firstAlternate.space?.id,
}
: {
pageID: currentPage?.id,
spaceID: currentPage?.spaceId,
};
// If there is no alternate link, we reconstruct the URL by swapping the space path.
// We need to ensure that the variant space URL is not the same as the current space path.
@@ -55,18 +44,9 @@ function useVariantSpaceHref(variantSpace: VariantSpace, currentSpacePath: strin
targetUrl.searchParams.set('fallback', 'true');
if (computed?.spaceID && computed?.pageID) {
targetUrl.searchParams.set('fallbackPageID', computed.pageID);
targetUrl.searchParams.set('fallbackSpaceID', computed.spaceID);
}
return targetUrl.toString();
}
if (computed?.spaceID && computed?.pageID) {
return `${joinPath(variantSpaceUrl, currentPathname)}?fallback=true&fallbackPageID=${computed.pageID}&fallbackSpaceID=${computed.spaceID}`;
}
// Fallback when the URL path is a relative path (in development mode)
return `${joinPath(variantSpaceUrl, currentPathname)}?fallback=true`;
}
@@ -15,7 +15,7 @@ import { notFound } from 'next/navigation';
import * as React from 'react';
import { DocumentView } from '@/components/DocumentView';
import { TrademarkLink } from '@/components/TableOfContents/Trademark';
import { Trademark } from '@/components/TableOfContents/Trademark';
import type { PolymorphicComponentProp } from '@/components/utils/types';
import { getSpaceLanguage } from '@/intl/server';
import { tString } from '@/intl/translate';
@@ -148,7 +148,7 @@ export async function PDFPage(props: {
total={total}
trademark={
customization.trademark.enabled ? (
<TrademarkLink
<Trademark
context={context}
placement={SiteInsightsTrademarkPlacement.Pdf}
/>
@@ -22,10 +22,6 @@ export async function PageHeader(props: {
const { context, page, ancestors, withRSSFeed } = props;
const { revision, linker } = context;
if (!page.layout.title && !page.layout.description) {
return null;
}
const hasAncestors = ancestors.length > 0;
// Show page actions if *any* of the actions are enabled
@@ -36,6 +32,21 @@ export async function PageHeader(props: {
withRSSFeed,
].some(Boolean);
/* When title and description are hidden, only display the page actions if there are any. */
if (!page.layout.title && !page.layout.description) {
if (!hasPageActions) {
return null;
}
return (
<PageActionsDropdown
siteTitle={context.site.title}
urls={getPageActionsURLs({ context, page, withRSSFeed })}
actions={context.customization.pageActions}
className="absolute top-8 right-0"
/>
);
}
return (
<header
className={tcls(
@@ -49,7 +60,7 @@ export async function PageHeader(props: {
hasAncestors ? 'page-has-ancestors' : 'page-no-ancestors'
)}
>
{page.layout.tableOfContents && hasPageActions ? (
{hasPageActions ? (
<PageActionsDropdown
siteTitle={context.site.title}
urls={getPageActionsURLs({ context, page, withRSSFeed })}
@@ -23,7 +23,7 @@ import * as ReactDOM from 'react-dom';
import { type FontData, getFontData } from '@/fonts';
import { fontNotoColorEmoji, fonts } from '@/fonts/default';
import { getSpaceLanguage } from '@/intl/server';
import { getSpaceLanguage, getSpaceLocale } from '@/intl/server';
import { getAssetURL } from '@/lib/assets';
import { tcls } from '@/lib/tailwind';
@@ -56,16 +56,19 @@ function preloadFont(fontData: FontData) {
* It takes care of setting the theme and the language.
*/
export async function CustomizationRootLayout(props: {
/** The class name to apply to the html element. */
htmlClassName?: string;
/** The class name to apply to the body element. */
className?: string;
bodyClassName?: string;
forcedTheme?: CustomizationThemeMode | null;
context: GitBookAnyContext;
children: React.ReactNode;
}) {
const { className, context, forcedTheme, children } = props;
const { htmlClassName, bodyClassName, context, forcedTheme, children } = props;
const customization =
'customization' in context ? context.customization : defaultCustomization();
const locale = getSpaceLocale(context);
const language = getSpaceLanguage(context);
const tintColor = getTintColor(customization);
const mixColor = getTintMixColor(customization.styling.primaryColor, tintColor);
@@ -88,7 +91,7 @@ export async function CustomizationRootLayout(props: {
return (
<html
suppressHydrationWarning
lang={customization.internationalization.locale}
lang={locale}
className={tcls(
customization.styling.corners && `${customization.styling.corners}-corners`,
'theme' in customization.styling && `theme-${customization.styling.theme}`,
@@ -106,7 +109,8 @@ export async function CustomizationRootLayout(props: {
// Set the dark/light class statically to avoid flashing and make it work when JS is disabled
(forcedTheme ?? customization.themes.default) === CustomizationThemeMode.Dark
? 'dark'
: ''
: '',
htmlClassName
)}
>
<head>
@@ -178,7 +182,7 @@ export async function CustomizationRootLayout(props: {
}
`}</style>
</head>
<body className={className}>
<body className={tcls(bodyClassName, 'sheet-open:overflow-hidden')}>
<IconsProvider
assetsURL={GITBOOK_ICONS_URL}
assetsURLToken={GITBOOK_ICONS_TOKEN}
@@ -101,6 +101,7 @@ export async function generateSiteLayoutViewport(context: GitBookSiteContext): P
width: 'device-width',
initialScale: 1,
maximumScale: 1,
viewportFit: 'cover',
};
}
@@ -11,13 +11,8 @@ import type { PageMetaLinks } from './SitePage';
*/
export function PageClientLayout({
pageMetaLinks,
currentPage,
}: {
pageMetaLinks: PageMetaLinks | null;
currentPage: {
id: string;
spaceId: string;
} | null;
}) {
// We use this hook in the page layout to ensure the elements for the blocks
// are rendered before we scroll to a hash or to the top of the page
@@ -26,7 +21,7 @@ export function PageClientLayout({
// The page metadata such as meta links are generated on the server side,
// but need to be registered on the client side in other parts of the layout
// such as the SpaceDropdown.
useRegisterPageMetadata({ pageMetaLinks, currentPage });
useRegisterPageMetadata({ pageMetaLinks });
useStripFallbackQueryParam();
return null;
@@ -49,8 +44,6 @@ function useStripFallbackQueryParam() {
if (searchParams?.has('fallback')) {
const params = new URLSearchParams(searchParams.toString());
params.delete('fallback');
params.delete('fallbackPageID');
params.delete('fallbackSpaceID');
router.push(`${pathname}?${params.toString()}${window.location.hash ?? ''}`);
}
}, [router, pathname, searchParams]);
@@ -61,13 +54,9 @@ function useStripFallbackQueryParam() {
*/
function useRegisterPageMetadata(metadata: {
pageMetaLinks: PageMetaLinks | null;
currentPage: {
id: string;
spaceId: string;
} | null;
}) {
const { pageMetaLinks, currentPage } = metadata;
const { pageMetaLinks } = metadata;
React.useEffect(() => {
currentPageMetadataStore.setState({ metaLinks: pageMetaLinks, currentPage });
}, [pageMetaLinks, currentPage]);
currentPageMetadataStore.setState({ metaLinks: pageMetaLinks });
}, [pageMetaLinks]);
}
@@ -47,11 +47,6 @@ export type PageMetaLinks = {
* Space the alternate link points to, if any.
*/
space: AlternateLinkSpace | null;
/**
* The page ID the alternate link points to, if any.
*/
pageID?: string;
}>;
};
@@ -71,6 +66,7 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
pageMetaLinks,
} = await getSitePageData(props);
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
return (
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
{/* Using `contents` makes the children of this div according to its parent — which keeps them in a single flex row with the TOC by default.
@@ -106,10 +102,7 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
staticRoute={props.staticRoute}
/>
</div>
<PageClientLayout
pageMetaLinks={pageMetaLinks}
currentPage={{ id: page.id, spaceId: context.space.id }}
/>
<PageClientLayout pageMetaLinks={pageMetaLinks} />
</div>
</PageContextProvider>
);
@@ -158,7 +151,7 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
});
if (!pageTarget) {
if (context.fallback.isFallback) {
if (context.isFallback) {
redirect(context.linker.toPathInSpace('/'));
}
notFound();
@@ -253,13 +246,12 @@ export async function getSitePageData(props: SitePageProps) {
redirect(context.linker.toPathInSpace(pathname));
} else {
// If the page is not found and we are in fallback mode, return a redirect to the basepath
if (context.fallback.isFallback) {
if (context.isFallback) {
redirect(context.linker.toPathInSpace('/'));
}
notFound();
}
} else if (getPagePath(context.revision.pages, pageTarget.page) !== rawPathname) {
// If the resolved page path doesn't match the requested pathname, redirect to the correct one
redirect(
context.linker.toPathForPage({
pages: context.revision.pages,
@@ -345,7 +337,6 @@ async function resolvePageMetaLinks(
space: resolved?.space
? { id: resolved.space.id, language: resolved.space.language }
: null,
pageID: resolved?.page?.id,
}))
);
@@ -357,13 +348,7 @@ async function resolvePageMetaLinks(
return {
canonical: resolvedCanonical ?? null,
alternates: resolvedAlternates.filter(
(
alt
): alt is {
href: string;
space: AlternateLinkSpace | null;
pageID: string | undefined;
} => !!alt.href
(alt): alt is { href: string; space: AlternateLinkSpace | null } => !!alt.href
),
};
}
@@ -2,7 +2,7 @@ import type { GitBookSiteContext } from '@/lib/context';
import { redirect } from 'next/navigation';
import { getDataOrNull } from '@/lib/data';
import { resolveFallbackPage, resolvePageId, resolvePagePath } from '@/lib/pages';
import { resolvePageId, resolvePagePath } from '@/lib/pages';
import { withLeadingSlash } from '@/lib/paths';
export interface PagePathParams {
@@ -36,8 +36,7 @@ export async function fetchPageData(context: GitBookSiteContext, params: PagePar
* If the path can't be found, we try to resolve it from the API to handle redirects.
*/
async function resolvePage(context: GitBookSiteContext, params: PagePathParams | PageIdParams) {
const { organizationId, site, space, revision, shareKey, linker, revisionId, fallback } =
context;
const { organizationId, site, space, revision, shareKey, linker, revisionId } = context;
if ('pageId' in params) {
return resolvePageId(revision.pages, params.pageId);
@@ -101,9 +100,6 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
);
if (resolved) {
return resolvePageId(revision.pages, resolved.id);
// biome-ignore lint/style/noUselessElse: Not useless
} else if (fallback.isFallback && fallback.pageID && fallback.spaceID) {
return resolveFallbackPage(revision.pages, fallback.spaceID, fallback.pageID);
}
}
@@ -40,7 +40,7 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
orientation="vertical"
style={{ maxHeight: `${MAX_ITEMS * 3 + 2}rem` }}
className="pb-4"
active={currentSection.id}
active={`#${currentSection.id}`}
>
<div className="flex w-full flex-col px-2">
{sectionsAndGroups.map((item) => {
@@ -76,8 +76,12 @@ export function SiteSectionTabs(props: {
? 'md:-mr-8 -mr-4 sm:-mr-6'
: 'after:contents[] after:absolute after:inset-y-2 after:right-0 after:border-transparent after:border-r after:transition-colors'
)}
active={currentSection.id}
trailingEdgeScrollClassName={children ? 'after:border-tint' : ''}
active={`#${currentSection.id}`}
trailing={{
fade: true,
button: true,
className: children ? 'after:border-tint' : '',
}}
>
<NavigationMenu.List
className={tcls(
@@ -128,7 +128,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
'lg:justify-center',
CONTAINER_STYLE,
'site-width-wide:max-w-screen-4xl',
'hydrated:transition-[max-width] duration-300',
'transition-[max-width] duration-300',
// Ensure the footer is display below the viewport even if the content is not enough
withFooter && [
@@ -141,79 +141,82 @@ export function SpaceLayout(props: SpaceLayoutProps) {
<TableOfContents
context={context}
header={
withTopHeader ? null : (
<div
className={tcls(
'hidden',
'pr-4',
'mt-2',
'lg:flex',
'grow-0',
'dark:shadow-light/1',
'text-base/tight',
'items-center'
)}
>
<HeaderLogo context={context} />
{variants.translations.length > 1 ? (
<TranslationsDropdown
context={context}
siteSpace={
variants.translations.find(
(space) => space.id === siteSpace.id
) ?? siteSpace
}
siteSpaces={variants.translations}
className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden"
/>
) : null}
</div>
)
<div
className={tcls(
'pr-4',
'flex',
withTopHeader ? 'lg:hidden' : '',
'grow-0',
'dark:shadow-light/1',
'text-base/tight',
'items-center'
)}
>
<HeaderLogo context={context} />
{variants.translations.length > 1 ? (
<TranslationsDropdown
context={context}
siteSpace={
variants.translations.find(
(space) => space.id === siteSpace.id
) ?? siteSpace
}
siteSpaces={variants.translations}
className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden"
/>
) : null}
</div>
}
// Displays the search button and/or the space dropdown in the ToC
// according to the header/variant settings.
// E.g if there is no header, the search button will be displayed in the ToC.
innerHeader={
<>
{!withTopHeader && (
<div className="flex gap-2">
<SearchContainer
style={CustomizationSearchStyle.Subtle}
withVariants={variants.generic.length > 1}
withSiteVariants={
visibleSections?.list.some(
(s) =>
s.object === 'site-section' &&
s.siteSpaces.length > 1
) ?? false
}
withSections={withSections}
section={visibleSections?.current}
siteSpace={siteSpace}
siteSpaces={visibleSiteSpaces}
className="max-lg:hidden"
viewport="desktop"
!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'
)}
>
{!withTopHeader && (
<div className="flex gap-2 max-lg:hidden">
<SearchContainer
style={CustomizationSearchStyle.Subtle}
withVariants={variants.generic.length > 1}
withSiteVariants={
visibleSections?.list.some(
(s) =>
s.object === 'site-section' &&
s.siteSpaces.length > 1
) ?? false
}
withSections={withSections}
section={visibleSections?.current}
siteSpace={siteSpace}
siteSpaces={visibleSiteSpaces}
viewport="desktop"
/>
</div>
)}
{!withTopHeader && withSections && visibleSections && (
<SiteSectionList
className="hidden lg:block"
sections={encodeClientSiteSections(
context,
visibleSections
)}
/>
</div>
)}
{!withTopHeader && withSections && visibleSections && (
<SiteSectionList
className={tcls('hidden', 'lg:block')}
sections={encodeClientSiteSections(
context,
visibleSections
)}
/>
)}
{variants.generic.length > 1 ? (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={variants.generic}
className="w-full px-3 py-2"
/>
) : null}
</>
)}
{variants.generic.length > 1 ? (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={variants.generic}
className="w-full px-3"
/>
) : null}
</div>
) : null
}
/>
{children}
@@ -32,7 +32,8 @@ export function PageDocumentItem(props: { page: ClientTOCPageDocument }) {
'my-2',
'border-tint-subtle',
'sidebar-list-default:border-l',
'sidebar-list-line:border-l'
'sidebar-list-line:border-l',
'break-anywhere'
)}
/>
) : null
@@ -14,9 +14,9 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
<li className="flex flex-col">
<div
className={tcls(
'-top-6 sticky z-1 flex items-center gap-3 px-3 pt-6',
'-top-4 sticky z-1 flex items-center gap-3 px-3',
'font-semibold text-xs uppercase tracking-wide',
'pb-3', // Add extra padding to make the header fade a bit nicer
'mt-2 pt-4 pb-3', // Add extra padding to make the header fade a bit nicer
'-mb-1.5', // Then pull the page items a bit closer, effective bottom padding is 1.5 units / 6px.
'mask-[linear-gradient(rgba(0,0,0,1)_70%,rgba(0,0,0,0))]', // Fade out effect of fixed page items. We want the fade to start past the header, this is a good approximation.
'bg-tint-base',
@@ -25,9 +25,9 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
'[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-default.theme-gradient_&]:bg-gradient-primary',
'[html.sidebar-default.theme-gradient.tint_&]:bg-gradient-tint',
isFirst ? '-mt-6' : ''
'lg:[html.sidebar-default.theme-gradient_&]:bg-gradient-primary',
'lg:[html.sidebar-default.theme-gradient.tint_&]:bg-gradient-tint',
isFirst ? '-mt-4' : ''
)}
>
<TOCPageIcon page={page} />
@@ -1,91 +0,0 @@
'use client';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
type ComponentPropsWithoutRef,
} from 'react';
import { assert } from 'ts-essentials';
interface TOCScrollContainerContextType {
getContainer: (listener: (element: HTMLDivElement) => void) => () => void;
}
const TOCScrollContainerContext = React.createContext<TOCScrollContainerContextType | null>(null);
function useTOCScrollContainerContext() {
const ctx = React.useContext(TOCScrollContainerContext);
assert(ctx);
return ctx;
}
/**
* Table of contents scroll container.
*/
export function TOCScrollContainer(props: ComponentPropsWithoutRef<'div'>) {
const ref = useRef<HTMLDivElement>(null);
const listeners = useRef<((element: HTMLDivElement) => void)[]>([]);
const getContainer: TOCScrollContainerContextType['getContainer'] = useCallback((listener) => {
if (ref.current) {
listener(ref.current);
return () => {};
}
listeners.current.push(listener);
return () => {
listeners.current = listeners.current.filter((l) => l !== listener);
};
}, []);
const value: TOCScrollContainerContextType = useMemo(() => ({ getContainer }), [getContainer]);
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
listeners.current.forEach((listener) => listener(element));
return () => {
listeners.current = [];
};
}, []);
return (
<TOCScrollContainerContext.Provider value={value}>
<div ref={ref} data-testid="toc-scroll-container" {...props} />
</TOCScrollContainerContext.Provider>
);
}
// Offset to scroll the table of contents item by.
const TOC_ITEM_OFFSET = 100;
/**
* Scrolls the table of contents container to the page item when it's initially active.
*/
export function useScrollToActiveTOCItem(props: {
anchorRef: React.RefObject<HTMLAnchorElement | null>;
isActive: boolean;
}) {
const { isActive, anchorRef } = props;
const { getContainer } = useTOCScrollContainerContext();
useEffect(() => {
const anchor = anchorRef.current;
if (isActive && anchor) {
return getContainer((container) => {
if (isOutOfView(anchor, container)) {
container.scrollTo({ top: anchor.offsetTop - TOC_ITEM_OFFSET });
}
});
}
}, [isActive, getContainer, anchorRef]);
}
function isOutOfView(element: HTMLElement, container: HTMLElement) {
const tocItemTop = element.offsetTop;
const containerTop = container.scrollTop;
const containerBottom = containerTop + container.clientHeight;
return (
tocItemTop < containerTop + TOC_ITEM_OFFSET ||
tocItemTop > containerBottom - TOC_ITEM_OFFSET
);
}
@@ -3,42 +3,59 @@ import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import type React from 'react';
import { tcls } from '@/lib/tailwind';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
import { PagesList } from './PagesList';
import { TOCScrollContainer } from './TOCScroller';
import { TableOfContentsScript } from './TableOfContentsScript';
import { Trademark } from './Trademark';
import { encodeClientTableOfContents } from './encodeClientTableOfContents';
/**
* Sidebar container, responsible for setting the right dimensions and position for the sidebar.
*/
export async function TableOfContents(props: {
context: GitBookSiteContext;
header?: React.ReactNode; // Displayed outside the scrollable TOC as a sticky header
innerHeader?: React.ReactNode; // Displayed outside the scrollable TOC, directly above the page list
withTrademark?: boolean;
className?: string;
}) {
const { innerHeader, context, header, className } = props;
const { innerHeader, context, header, className, withTrademark = true } = props;
const { customization, revision } = context;
const pages = await encodeClientTableOfContents(context, revision.pages, revision.pages);
return (
<>
<aside // Sidebar container, responsible for setting the right dimensions and position for the sidebar.
<SideSheet
side="left"
data-testid="table-of-contents"
id="table-of-contents"
toggleClass="navigation-open"
withOverlay={true}
withCloseButton={true}
className={tcls(
'group',
'group/table-of-contents',
'text-sm',
'grow-0',
'shrink-0',
'basis-full',
'lg:basis-72',
'w-4/5',
'md:w-1/2',
'lg:w-72',
'basis-72',
'lg:page-no-toc:basis-56',
'relative',
'z-1',
'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!',
// Server-side static positioning
'lg:top-0',
@@ -60,30 +77,22 @@ export async function TableOfContents(props: {
'lg:page-no-toc:[html[style*="--outline-top-offset"]_&]:top-(--outline-top-offset)!',
'lg:page-no-toc:[html[style*="--outline-height"]_&]:top-(--outline-height)!',
'pt-4',
'pb-4',
'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:sidebar-filled:pr-6',
'lg:page-no-toc:pr-0',
'max-lg:pl-8',
'hidden',
'navigation-open:flex!',
'lg:flex',
'lg:page-no-toc:hidden',
'xl:page-no-toc:flex',
'lg:site-header-none:page-no-toc:flex',
'flex-col',
'gap-4',
'navigation-open:border-b',
'border-tint-subtle',
className
)}
>
{header && header}
{header}
<div // The actual sidebar, either shown with a filled bg or transparent.
className={tcls(
'lg:-ms-5',
'relative flex grow flex-col overflow-hidden border-tint-subtle',
'-ms-5',
'relative flex min-h-0 grow flex-col border-tint-subtle',
'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
@@ -91,40 +100,43 @@ export async function TableOfContents(props: {
'[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',
'page-no-toc:bg-transparent!',
'page-no-toc:border-none!',
'sidebar-filled:rounded-xl',
'sidebar-filled:rounded-2xl',
'straight-corners:rounded-none',
'page-has-toc:[html.sidebar-filled.circular-corners_&]:rounded-3xl'
'page-has-toc:[html.sidebar-filled.circular-corners_&]:rounded-4xl'
)}
>
{innerHeader ? (
<div className="my-4 flex flex-col space-y-4 px-5 empty:hidden">
{innerHeader}
</div>
) : null}
<TOCScrollContainer // The scrollview inside the sidebar
className={tcls(
'flex grow flex-col p-2 pt-4',
customization.trademark.enabled && 'lg:pb-20',
'hide-scrollbar overflow-y-auto'
)}
{innerHeader}
<ScrollContainer
data-testid="toc-scroll-container"
orientation="vertical"
contentClassName="flex flex-col p-2 gutter-stable"
active="[data-active=true]"
leading={{
fade: true,
button: {
className: '-mt-4',
},
}}
>
<PagesList
pages={pages}
isRoot={true}
style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l"
style="page-no-toc:hidden grow border-tint-subtle sidebar-list-line:border-l"
/>
{customization.trademark.enabled ? (
<Trademark
context={context}
placement={SiteInsightsTrademarkPlacement.Sidebar}
/>
) : null}
</TOCScrollContainer>
</ScrollContainer>
{withTrademark && customization.trademark.enabled ? (
<Trademark
context={context}
placement={SiteInsightsTrademarkPlacement.Sidebar}
className="m-2 mt-auto sidebar-default:mr-4"
/>
) : null}
</div>
</aside>
</SideSheet>
<TableOfContentsScript />
</>
);
@@ -3,7 +3,6 @@ import { AnimatePresence, motion } from 'motion/react';
import React, { useRef } from 'react';
import { useCurrentPagePath } from '../hooks';
import { Button, Link, type LinkInsightsProps, type LinkProps, ToggleChevron } from '../primitives';
import { useScrollToActiveTOCItem } from './TOCScroller';
/**
* Client component for a page document to toggle its children and be marked as active.
@@ -76,8 +75,6 @@ function LinkItem(
}
) {
const { isActive, href, insights, children, onActiveClick } = props;
const anchorRef = useRef<HTMLAnchorElement>(null);
useScrollToActiveTOCItem({ anchorRef, isActive });
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (isActive && onActiveClick) {
@@ -88,7 +85,7 @@ function LinkItem(
return (
<Link
ref={anchorRef}
data-active={isActive}
href={href}
insights={insights}
aria-current={isActive ? 'page' : undefined}
@@ -1,11 +1,11 @@
import type { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { getSpaceLanguage, t } from '@/intl/server';
import { getSpaceLanguage, tString } from '@/intl/server';
import { tcls } from '@/lib/tailwind';
import type { GitBookSpaceContext } from '@/lib/context';
import { Link } from '../primitives';
import { Button } from '../primitives';
/**
* Trademark link to the GitBook.
@@ -14,69 +14,6 @@ export function Trademark(props: {
context: GitBookSpaceContext;
placement: SiteInsightsTrademarkPlacement;
className?: string;
}) {
const { className, ...rest } = props;
return (
<div
className={tcls(
'relative',
'z-2',
'lg:absolute',
'left-0',
'right-2',
'bottom-0',
'pointer-events-none',
'sidebar-filled:pl-2',
'sidebar-filled:pb-2',
'sidebar-filled:page-no-toc:p-0',
'bg-tint-base',
'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'rounded-lg',
'straight-corners:rounded-none',
'circular-corners:rounded-2xl',
'before:hidden',
'lg:before:block',
'before:content-[""]',
'before:absolute',
'before:inset-x-0',
'before:bottom-full',
'before:h-8',
'before:bg-linear-to-b',
'before:from-transparent',
'before:to-tint-base',
'sidebar-filled:before:to-tint-subtle',
'theme-muted:before:to-tint-subtle',
'[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:before:to-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-base',
'page-no-toc:before:to-transparent!',
className
)}
>
<TrademarkLink
className="circular-corners:rounded-2xl rounded-lg straight-corners:rounded-none"
{...rest}
/>
</div>
);
}
/**
* Trademark link to the GitBook.
*/
export function TrademarkLink(props: {
context: GitBookSpaceContext;
placement: SiteInsightsTrademarkPlacement;
className?: string;
}) {
const { context, placement, className } = props;
const { space } = context;
@@ -88,8 +25,10 @@ export function TrademarkLink(props: {
url.searchParams.set('utm_campaign', space.id);
return (
<Link
<Button
target="_blank"
variant="secondary"
size="large"
href={url.toString()}
className={tcls(
'text-sm',
@@ -101,30 +40,21 @@ export function TrademarkLink(props: {
'items-center',
'px-5',
'py-4',
'gap-3',
'whitespace-normal',
'sidebar-filled:px-3',
'lg:sidebar-filled:page-no-toc:px-5',
'hover:bg-tint',
'hover:text-tint-strong',
'ring-2',
'lg:ring-1',
'ring-inset',
'ring-tint-subtle',
'transition-colors',
'pointer-events-auto',
'bg-transparent',
'depth-subtle:shadow-none',
'border-tint-subtle',
className
)}
icon={<Icon icon="gitbook" className="size-5 shrink-0" />}
label={tString(language, 'powered_by_gitbook')}
insights={{
type: 'trademark_click',
placement,
}}
>
<Icon icon="gitbook" className={tcls('size-5', 'shrink-0')} />
<span className={tcls('ml-3')}>{t(language, 'powered_by_gitbook')}</span>
</Link>
/>
);
}
@@ -1,4 +1,3 @@
export { TableOfContents } from './TableOfContents';
export { PagesList } from './PagesList';
export { TOCScrollContainer } from './TOCScroller';
export { Trademark } from './Trademark';
@@ -11,18 +11,16 @@ import * as zustand from 'zustand';
*/
export const currentPageMetadataStore = zustand.create<{
metaLinks: PageMetaLinks | null;
currentPage: {
id: string;
spaceId: string;
} | null;
}>(() => ({
metaLinks: null,
currentPage: null,
}));
/**
* Return the metadata for the current page.
*/
export function useCurrentPageMetadata() {
return zustand.useStore(currentPageMetadataStore, (state) => state);
const metaLinks = zustand.useStore(currentPageMetadataStore, (state) => state.metaLinks);
return {
metaLinks,
};
}
+3 -3
View File
@@ -9,9 +9,9 @@ export const HEADER_HEIGHT_DESKTOP = 64 as const;
* Style for the container to adapt between normal and full width.
*/
export const CONTAINER_STYLE: ClassValue = [
'px-4',
'sm:px-6',
'md:px-8',
'px-4 pl-[max(env(safe-area-inset-left),1rem)] pr-[max(env(safe-area-inset-right),1rem)]',
'sm:px-6 sm:pl-[max(env(safe-area-inset-left),1.5rem)] sm:pr-[max(env(safe-area-inset-right),1.5rem)]',
'md:px-8 md:pl-[max(env(safe-area-inset-left),2rem)] md:pr-[max(env(safe-area-inset-right),2rem)]',
'max-w-screen-2xl',
'mx-auto',
];
@@ -28,7 +28,7 @@ export function HoverCard(
<RadixHoverCard.Portal>
<RadixHoverCard.Content
side={props.side ?? 'top'}
className="pointer-events-none z-40 w-screen max-w-md animate-scale-in px-4 data-[state='closed']:animate-scale-out sm:w-auto"
className="pointer-events-none z-50 w-screen max-w-md animate-scale-in px-4 data-[state='closed']:animate-scale-out sm:w-auto"
>
<div
className={tcls(
@@ -4,7 +4,7 @@ import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import * as React from 'react';
import { useScrollListener } from '../hooks/useScrollListener';
import { Button } from './Button';
import { Button, type ButtonProps } from './Button';
/**
* A container that encapsulates a scrollable area with usability features.
@@ -17,17 +17,26 @@ export type ScrollContainerProps = {
className?: string;
contentClassName?: string;
/** Optional class(es) to apply when there the container can be scrolled on the leading (left or top) edge */
leadingEdgeScrollClassName?: string;
/** Optional class(es) to apply when there the container can be scrolled on the trailing (right or bottom) edge */
trailingEdgeScrollClassName?: string;
/** The direction of the scroll container. */
orientation: 'horizontal' | 'vertical';
/** Whether to fade out the edges of the container. */
fadeEdges?: ('leading' | 'trailing')[];
leading?: {
/** Whether to fade out the leading edge of the container. */
fade: boolean;
/** Whether to show a button to scroll back. */
button: boolean | ButtonProps;
/** Optional class(es) to apply when there the container can be scrolled on the leading (left or top) edge */
className?: string;
};
trailing?: {
/** Whether to fade out the trailing edge of the container. */
fade: boolean;
/** Whether to show a button to scroll forward. */
button: boolean | ButtonProps;
/** Optional class(es) to apply when there the container can be scrolled on the trailing (right or bottom) edge */
className?: string;
};
/** The ID or ref of the active item to scroll to. */
active?: string | React.RefObject<HTMLElement | null>;
@@ -39,10 +48,9 @@ export function ScrollContainer(props: ScrollContainerProps) {
className,
contentClassName,
orientation,
fadeEdges = ['leading', 'trailing'],
active,
leadingEdgeScrollClassName,
trailingEdgeScrollClassName,
leading = { fade: true, button: true },
trailing = { fade: true, button: true },
...rest
} = props;
@@ -102,7 +110,9 @@ export function ScrollContainer(props: ScrollContainerProps) {
return;
}
const activeItem =
typeof active === 'string' ? document.getElementById(active) : active.current;
typeof active === 'string'
? containerRef.current?.querySelector(active)
: active.current;
if (!activeItem || !container.contains(activeItem)) {
return;
}
@@ -138,28 +148,30 @@ export function ScrollContainer(props: ScrollContainerProps) {
return (
<div
className={tcls(
'group/scroll-container relative flex overflow-hidden',
'group/scroll-container relative flex shrink grow',
orientation === 'horizontal' ? 'min-w-0' : 'min-h-0',
className,
scrollPosition > 0 ? leadingEdgeScrollClassName : '',
scrollPosition < scrollSize ? trailingEdgeScrollClassName : ''
scrollPosition > 0 ? leading?.className : '',
scrollPosition < scrollSize ? trailing?.className : ''
)}
{...rest}
>
{/* Scrollable content */}
<div
className={tcls(
'flex shrink grow',
'flex flex-1 overflow-hidden',
orientation === 'horizontal' ? 'min-w-0' : 'min-h-0',
orientation === 'horizontal' ? 'no-scrollbar' : 'hide-scrollbar',
orientation === 'horizontal' ? 'overflow-x-scroll' : 'flex-col overflow-y-auto',
fadeEdges.includes('leading') && scrollPosition > 0
leading.fade && scrollPosition > 0
? orientation === 'horizontal'
? 'mask-l-from-[calc(100%-2rem)]'
: 'mask-t-from-[calc(100%-2rem)]'
? 'mask-l-from-[calc(100%-1rem)]'
: 'mask-t-from-[calc(100%-1rem)]'
: '',
fadeEdges.includes('trailing') && scrollPosition < scrollSize
trailing.fade && scrollPosition < scrollSize
? orientation === 'horizontal'
? 'mask-r-from-[calc(100%-2rem)]'
: 'mask-b-from-[calc(100%-2rem)]'
? 'mask-r-from-[calc(100%-1rem)]'
: 'mask-b-from-[calc(100%-1rem)]'
: '',
contentClassName
)}
@@ -169,44 +181,52 @@ export function ScrollContainer(props: ScrollContainerProps) {
</div>
{/* Scroll buttons back & forward */}
<Button
icon={orientation === 'horizontal' ? 'chevron-left' : 'chevron-up'}
iconOnly
size="xsmall"
variant="secondary"
tabIndex={-1}
className={tcls(
'bg-tint-base!',
orientation === 'horizontal'
? '-translate-y-1/2! top-1/2 left-0 ml-2'
: '-translate-x-1/2! top-0 left-1/2 mt-2',
'absolute not-pointer-none:block hidden scale-0 opacity-0 transition-[scale,opacity]',
scrollPosition > 0
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
: 'pointer-events-none'
)}
onClick={scrollBack}
label={tString(language, 'scroll_back')}
/>
<Button
icon={orientation === 'horizontal' ? 'chevron-right' : 'chevron-down'}
iconOnly
size="xsmall"
variant="secondary"
tabIndex={-1}
className={tcls(
'bg-tint-base!',
orientation === 'horizontal'
? '-translate-y-1/2! top-1/2 right-0 mr-2'
: '-translate-x-1/2! bottom-0 left-1/2 mb-2',
'absolute not-pointer-none:block hidden scale-0 transition-[scale,opacity]',
scrollPosition < scrollSize
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
: 'pointer-events-none'
)}
onClick={scrollFurther}
label={tString(language, 'scroll_further')}
/>
{leading.button !== false ? (
<Button
icon={orientation === 'horizontal' ? 'chevron-left' : 'chevron-up'}
iconOnly
size="xsmall"
variant="secondary"
tabIndex={-1}
onClick={scrollBack}
label={tString(language, 'scroll_back')}
{...(typeof leading.button === 'object' ? leading.button : {})}
className={tcls(
'bg-tint-base!',
orientation === 'horizontal'
? '-translate-y-1/2! top-1/2 left-0 ml-2'
: '-translate-x-1/2! top-0 left-1/2 mt-2',
'absolute z-10 not-pointer-none:block hidden scale-0 opacity-0 transition-[scale,opacity]',
scrollPosition > 0
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
: 'pointer-events-none',
typeof leading.button === 'object' ? leading.button.className : ''
)}
/>
) : null}
{trailing.button !== false ? (
<Button
icon={orientation === 'horizontal' ? 'chevron-right' : 'chevron-down'}
iconOnly
size="xsmall"
variant="secondary"
tabIndex={-1}
onClick={scrollFurther}
label={tString(language, 'scroll_further')}
{...(typeof trailing.button === 'object' ? trailing.button : {})}
className={tcls(
'bg-tint-base!',
orientation === 'horizontal'
? '-translate-y-1/2! top-1/2 right-0 mr-2'
: '-translate-x-1/2! bottom-0 left-1/2 mb-2',
'absolute z-10 not-pointer-none:block hidden scale-0 transition-[scale,opacity]',
scrollPosition < scrollSize
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
: 'pointer-events-none',
typeof trailing.button === 'object' ? trailing.button.className : ''
)}
/>
) : null}
</div>
);
}
@@ -214,7 +234,7 @@ export function ScrollContainer(props: ScrollContainerProps) {
/**
* Scroll to an element in a container.
*/
function scrollToElementInContainer(element: HTMLElement, container: HTMLElement) {
function scrollToElementInContainer(element: Element, container: HTMLElement) {
const containerRect = container.getBoundingClientRect();
const rect = element.getBoundingClientRect();
@@ -229,6 +249,8 @@ function scrollToElementInContainer(element: HTMLElement, container: HTMLElement
(rect.left - containerRect.left) -
container.clientWidth / 2 +
rect.width / 2,
behavior: 'smooth',
// Use 'auto' to avoid additional scroll animations when scrolling to an element
// as this may be called during layout/initialization when the page is not fully loaded.
behavior: 'auto',
});
}
@@ -0,0 +1,276 @@
'use client';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import { type ClassValue, tcls } from '@/lib/tailwind';
import React from 'react';
import { useIsMobile } from '../hooks/useIsMobile';
import { Button } from './Button';
const ANIMATION_DURATION = 300;
/**
* SideSheet - A slide-in panel component that can appear from the left or right side.
*
* Supports both controlled and uncontrolled modes:
* - Controlled: Provide both `open` and `onOpenChange` props. Parent manages state.
* - Uncontrolled: Omit `open` prop. Component manages its own state internally.
*/
export function SideSheet(
props: {
/** Which side the sheet slides in from */
side: 'left' | 'right';
/**
* Optional CSS class to monitor and sync with `document.body.classList`.
* When set, a MutationObserver watches for the class and syncs the sheet state accordingly.
* Adding this class opens the sheet, removing it closes it.
* Works in both controlled and uncontrolled modes.
*/
toggleClass?: string;
/**
* Modal behavior: true (always modal), false (never modal), or 'mobile' (modal only on mobile).
* Defaults to 'mobile'.
*/
modal?: true | false | 'mobile';
/**
* Controls visibility. If provided, component is controlled (parent manages state).
* If undefined, component is uncontrolled (manages its own state).
*/
open?: boolean;
/** Called when the open state changes. Receives the new state (true/false). Only used in controlled mode. */
onOpenChange?: (open: boolean) => void;
/** Show a backdrop overlay when modal */
withOverlay?: boolean;
/** Show a close button when modal */
withCloseButton?: boolean;
} & React.HTMLAttributes<HTMLDivElement>
) {
const {
side,
children,
className,
toggleClass,
open: openState,
modal = 'mobile',
withOverlay,
withCloseButton,
onOpenChange,
...rest
} = props;
const isMobile = useIsMobile();
const isModal = modal === 'mobile' ? isMobile : modal;
const asideRef = React.useRef<HTMLElement>(null);
// Internal state for uncontrolled mode (only used when open prop is undefined)
const [open, setOpen] = React.useState(openState ?? false);
// Determine actual open state: controlled (from prop) or uncontrolled (from internal state)
const isOpen = openState !== undefined ? openState : open;
const wasOpenRef = React.useRef(false);
const [shouldHide, setShouldHide] = React.useState(!isOpen);
// Track if component has been opened to prevent initial animation
React.useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined;
if (isOpen) {
wasOpenRef.current = true;
setShouldHide(false);
} else if (wasOpenRef.current) {
timer = setTimeout(() => {
setShouldHide(true);
}, ANIMATION_DURATION);
} else {
setShouldHide(true);
}
return () => {
if (timer) clearTimeout(timer);
};
}, [isOpen]);
const handleClose = React.useCallback(() => {
if (openState !== undefined) {
// Controlled mode: parent manages state, notify via callback with new state
onOpenChange?.(false);
} else {
// Uncontrolled mode: update internal state and sync body class if needed
setOpen(false);
if (toggleClass) {
document.body.classList.remove(toggleClass);
}
}
}, [openState, onOpenChange, toggleClass]);
// Sync the sheet state with the body class if the toggleClass is set
React.useEffect(() => {
if (!toggleClass) {
return;
}
const callback = (mutationList: MutationRecord[]) => {
for (const mutation of mutationList) {
if (mutation.attributeName === 'class') {
const shouldBeOpen = document.body.classList.contains(toggleClass);
if (openState !== undefined) {
// Controlled mode: sync with parent's state
// Notify parent of state change via onOpenChange
if (shouldBeOpen !== openState) {
onOpenChange?.(shouldBeOpen);
}
} else {
// Uncontrolled mode: sync internal state with body class
setOpen(shouldBeOpen);
}
}
}
};
const observer = new MutationObserver(callback);
observer.observe(document.body, { attributes: true });
return () => observer.disconnect();
}, [toggleClass, openState, onOpenChange]);
// Handle Escape key press to close the modal sheet
React.useEffect(() => {
if (!isModal || !isOpen) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
handleClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isModal, isOpen, handleClose]);
// Focus trapping: prevent Tab from leaving the modal
React.useEffect(() => {
if (!isModal || !isOpen || !asideRef.current) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') {
return;
}
const aside = asideRef.current;
if (!aside) {
return;
}
const focusable = aside.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
const current = document.activeElement as HTMLElement;
if (!aside.contains(current)) {
// Focus escaped, bring it back
(event.shiftKey ? last : first)?.focus();
event.preventDefault();
} else if (event.shiftKey && current === first) {
// Shift+Tab at first element, wrap to last
last?.focus();
event.preventDefault();
} else if (!event.shiftKey && current === last) {
// Tab at last element, wrap to first
first?.focus();
event.preventDefault();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isModal, isOpen]);
return (
<>
{withOverlay ? (
<SideSheetOverlay
className={tcls(isModal && isOpen ? '' : 'hidden opacity-0 backdrop-blur-none')}
onClick={handleClose}
/>
) : null}
<aside
ref={asideRef}
className={tcls(
'side-sheet',
'fixed inset-y-0 z-41', // Above the side sheet overlay on z-40
side === 'left' ? 'left-0' : 'right-0',
withCloseButton ? 'max-w-[calc(100%-4rem)]' : 'max-w-[calc(100%-3rem)]',
shouldHide ? 'hidden' : '',
isOpen
? side === 'left'
? 'hydrated:animate-enter-from-left'
: 'hydrated:animate-enter-from-right'
: '',
!isOpen && wasOpenRef.current
? side === 'left'
? 'hydrated:animate-exit-to-left'
: 'hydrated:animate-exit-to-right'
: '',
className
)}
aria-expanded={isOpen}
aria-modal={isModal}
{...rest}
>
{children}
{withCloseButton ? (
<SideSheetCloseButton
className={tcls(
side === 'left' ? 'left-full ml-4' : 'right-full mr-4',
isModal && isOpen ? 'animate-blur-in' : 'hidden animate-blur-out'
)}
onClick={handleClose}
/>
) : null}
</aside>
</>
);
}
/** Backdrop overlay shown behind the modal sheet */
export function SideSheetOverlay(props: { className?: ClassValue; onClick?: () => void }) {
const { className, onClick } = props;
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: global escape key handler is used to close the modal sheet
<div
id="side-sheet-overlay"
onClick={() => {
onClick?.();
}}
className={tcls(
'fixed inset-0 z-40 items-start bg-tint-base/3 not-hydrated:opacity-0 starting:opacity-0 backdrop-blur-md starting:backdrop-blur-none transition-[opacity,display,backdrop-filter] transition-discrete duration-250 dark:bg-tint-base/6',
className
)}
/>
);
}
/** Close button displayed outside the sheet when modal */
export function SideSheetCloseButton(props: { className?: ClassValue; onClick?: () => void }) {
const { className, onClick } = props;
const language = useLanguage();
return (
<Button
icon="xmark"
variant="secondary"
iconOnly
label={tString(language, 'close')}
className={tcls('absolute top-4 bg-tint-base! transition-discrete', className)}
onClick={() => {
onClick?.();
}}
/>
);
}
@@ -33,7 +33,11 @@ export type ImageResponsiveSize = {
width: number;
};
const MAX_DPR = 4;
/**
* Maximum device pixel ratio (DPR) to generate in srcSet.
* Limit to 3 to align with routes/image.ts
*/
const MAX_DPR = 3;
interface ImageCommonProps {
/**
+8 -26
View File
@@ -27,25 +27,6 @@ import { GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images';
import { type GitBookLinker, createLinker, linkerForPublishedURL } from './links';
type FallbackData = {
/**
*
*/
isFallback: boolean;
/**
* Space ID of the main space.
* Only provided for a computed space.
*/
spaceID?: string;
/**
* page ID of the main revision.
* Only provided for a computed revision.
*/
pageID?: string;
};
/**
* Data about the site URL. Provided by the middleware.
* These data are stable between pages in the same site space.
@@ -71,10 +52,11 @@ export type SiteURLData = Pick<
imagesContextId: string;
/**
* Necessary data to properly handle fallback rendering.
* This is used when switching between variants to avoid fetching every revision every time.
* Whether this request is a fallback rendering.
* We use this when switching variant as we don't know if the page exists in the other variant.
* By knowing it's a fallback, we can redirect to the space base path instead of returning a 404.
*/
fallback?: FallbackData;
isFallback?: boolean;
};
/**
@@ -160,7 +142,7 @@ export type GitBookSiteContext = GitBookSpaceContext & {
contextId?: string;
/** Whether this request is a fallback rendering. */
fallback: FallbackData;
isFallback: boolean;
};
/**
@@ -239,7 +221,7 @@ export async function fetchSiteContextByURLLookup(
changeRequest: data.changeRequest,
revision: data.revision,
contextId: data.contextId,
fallback: data.fallback ?? { isFallback: false },
isFallback: data.isFallback ?? false,
});
}
@@ -258,7 +240,7 @@ export async function fetchSiteContextByIds(
changeRequest: string | undefined;
revision: string | undefined;
contextId?: string;
fallback: FallbackData;
isFallback: boolean;
}
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
@@ -370,7 +352,7 @@ export async function fetchSiteContextByIds(
visibleSections,
scripts,
contextId: ids.contextId,
fallback: ids.fallback,
isFallback: ids.isFallback,
};
}
-51
View File
@@ -116,57 +116,6 @@ export function resolvePageId(
return iteratePages(rootPages, []);
}
/**
*
* Resolve a page by its related space ID and page ID from the revision pages.
* Related pages are pages that are linked via meta links alternates.
* It uses the metaLinks alternates to find the matching computed page.
* It always use the first alternate to resolve the parent page.
*
* @param rootPages
* @param relatedSpaceId Space ID of the parent page (i.e. )
* @param relatedPageId
* @returns
*/
export function resolveFallbackPage(
rootPages: Revision['pages'],
relatedSpaceId: string,
relatedPageId: string
): { page: RevisionPageDocument; ancestors: AncestorRevisionPage[] } | undefined {
const iteratePages = (
pages: RevisionPage[],
ancestors: AncestorRevisionPage[]
): { page: RevisionPageDocument; ancestors: AncestorRevisionPage[] } | undefined => {
for (const page of pages) {
if (page.type === RevisionPageType.Link || page.type === RevisionPageType.Computed) {
continue;
}
if (page.type === RevisionPageType.Document) {
const metaLinksAlternates = page.metaLinks?.alternates;
if (
metaLinksAlternates &&
metaLinksAlternates.length > 0 &&
metaLinksAlternates[0]?.kind !== 'url'
) {
if (
metaLinksAlternates[0]?.space === relatedSpaceId &&
metaLinksAlternates[0]?.page === relatedPageId
) {
return resolvePageDocument(page, ancestors);
}
}
}
const result = iteratePages(page.pages, [...ancestors, page]);
if (result) {
return result;
}
}
};
return iteratePages(rootPages, []);
}
/**
* Resolve the next/previous page before another one.
* It ignores hidden pages as this is used for navigation purpose.
+1
View File
@@ -76,6 +76,7 @@ export function defaultCustomization(): api.SiteCustomizationSettings {
url: 'https://www.gitbook.com/privacy',
},
socialPreview: {},
socialAccounts: [],
};
}
+34 -25
View File
@@ -1,4 +1,5 @@
import { CustomizationThemeMode } from '@gitbook/api';
import Negotiator from 'negotiator';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import rison from 'rison';
@@ -282,13 +283,6 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// (customization override, theme, etc)
let routeType: 'dynamic' | 'static' = 'static';
// Extract fallback pageID and spaceID from the URL
// These are used when switching variant spaces to redirect to a specific page if the current path doesn't exist in the new variant.
// Because there is no link between every variant, we need to pass a stable spaceID/pageID to redirect to.
// They are the first alternate link in the page metadata of the previous variant.
const fallbackPageID = requestURL.searchParams.get('fallbackPageID');
const fallbackSpaceID = requestURL.searchParams.get('fallbackSpaceID');
// We pick only stable data from the siteURL data to prevent re-rendering of
// the root layout when changing pages..
const stableSiteURLData: SiteURLData = {
@@ -305,11 +299,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
apiToken: siteURLData.apiToken,
imagesContextId: imagesContextId,
contextId: siteURLData.contextId,
fallback: {
isFallback: requestURL.searchParams.get('fallback') === 'true',
...(fallbackPageID ? { pageID: fallbackPageID } : {}),
...(fallbackSpaceID ? { spaceID: fallbackSpaceID } : {}),
},
isFallback: requestURL.searchParams.get('fallback') === 'true' ? true : undefined,
};
const requestHeaders = new Headers(request.headers);
@@ -371,7 +361,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
const siteURLWithoutProtocol = `${siteCanonicalURL.host}${siteURLData.basePath}`;
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(
siteURLData.pathname
siteURLData.pathname,
request
);
routeType = routeTypeFromPathname ?? routeType;
@@ -580,7 +571,10 @@ const EMBED_PAGE_PATH_REGEX = /^~gitbook\/embed\/page(\/(\S*))?$/;
* Encode path in a site content.
* Special paths are not encoded and passed to be handled by the route handlers.
*/
function encodePathInSiteContent(rawPathname: string): {
function encodePathInSiteContent(
rawPathname: string,
request: Request
): {
pathname: string;
routeType?: 'static' | 'dynamic';
} {
@@ -599,16 +593,6 @@ function encodePathInSiteContent(rawPathname: string): {
};
}
// If the pathname is a markdown file, we rewrite it to ~gitbook/markdown/:pathname
if (pathname.match(MARKDOWN_PATH_REGEX)) {
const pagePathWithoutMD = pathname.slice(0, -3);
return {
pathname: `~gitbook/markdown/${encodePagePath(pagePathWithoutMD)}`,
// The markdown content is always static and doesn't depend on the dynamic parameter (customization, theme, etc)
routeType: 'static',
};
}
// We skip encoding for paginated llms-full.txt pages (i.e. llms-full.txt/100)
if (pathname.match(LLMS_FULL_PATH_REGEX)) {
return { pathname, routeType: 'static' };
@@ -629,6 +613,7 @@ function encodePathInSiteContent(rawPathname: string): {
return { pathname };
case '~gitbook/mcp':
case 'llms.txt':
case 'llms-full.txt':
case 'sitemap.xml':
case 'sitemap-pages.xml':
case 'robots.txt':
@@ -640,8 +625,19 @@ function encodePathInSiteContent(rawPathname: string): {
case '~gitbook/pdf':
// PDF routes are always dynamic as they depend on the search params.
return { pathname, routeType: 'dynamic' };
default:
default: {
// If the pathname is a markdown file or the request is accepting markdown,
// we rewrite it to ~gitbook/markdown/:pathname
if (pathname.match(MARKDOWN_PATH_REGEX) || isMarkdownPreferred(request)) {
const pagePathWithoutMD = pathname.replace(MARKDOWN_PATH_REGEX, '');
return {
pathname: `~gitbook/markdown/${encodePagePath(pagePathWithoutMD)}`,
// The markdown content is always static and doesn't depend on the dynamic parameter (customization, theme, etc)
routeType: 'static',
};
}
return { pathname: encodePagePath(pathname) };
}
}
}
@@ -677,3 +673,16 @@ async function writeResponseCookies<R extends NextResponse>(
return response;
}
const MARKDOWN_MEDIA_TYPES = ['text/markdown'];
/**
* Test if a request is requesting a markdown version of the page.
*/
function isMarkdownPreferred(request: Request): boolean {
const negotiator = new Negotiator({
headers: Object.fromEntries(request.headers.entries()),
});
const mediaTypes = negotiator.mediaTypes();
return MARKDOWN_MEDIA_TYPES.some((mediaType) => mediaTypes.includes(mediaType));
}
+13 -9
View File
@@ -327,10 +327,10 @@ const config: Config = {
blurOut: 'blurOut 200ms ease-in both',
'blurOut-slow': 'blurOut 500ms ease-in both',
enterFromLeft: 'enterFromLeft 250ms cubic-bezier(0.83, 0, 0.17, 1) both',
enterFromRight: 'enterFromRight 250ms cubic-bezier(0.83, 0, 0.17, 1) both',
exitToLeft: 'exitToLeft 250ms cubic-bezier(0.83, 0, 0.17, 1) both',
exitToRight: 'exitToRight 250ms cubic-bezier(0.83, 0, 0.17, 1) both',
enterFromLeft: 'enterFromLeft 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
enterFromRight: 'enterFromRight 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
exitToLeft: 'exitToLeft 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
exitToRight: 'exitToRight 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
heightIn: 'heightIn 200ms ease both',
crawl: 'crawl 2s ease-in-out infinite',
@@ -473,18 +473,18 @@ const config: Config = {
},
enterFromRight: {
from: { opacity: '0', transform: 'translateX(50%)', display: 'none' },
to: { opacity: '1', transform: 'translateX(0)', display: 'block' },
to: { opacity: '1', transform: 'translateX(0)', display: 'inherit' },
},
enterFromLeft: {
from: { opacity: '0', transform: 'translateX(-50%)', display: 'none' },
to: { opacity: '1', transform: 'translateX(0)', display: 'block' },
to: { opacity: '1', transform: 'translateX(0)', display: 'inherit' },
},
exitToRight: {
from: { opacity: '1', transform: 'translateX(0)', display: 'block' },
from: { opacity: '1', transform: 'translateX(0)', display: 'inherit' },
to: { opacity: '0', transform: 'translateX(50%)', display: 'none' },
},
exitToLeft: {
from: { opacity: '1', transform: 'translateX(0)', display: 'block' },
from: { opacity: '1', transform: 'translateX(0)', display: 'inherit' },
to: { opacity: '0', transform: 'translateX(-50%)', display: 'none' },
},
scaleIn: {
@@ -591,7 +591,11 @@ const config: Config = {
* Variant when the Table of Content navigation is open.
*/
addVariant('navigation-open', 'body.navigation-open &');
addVariant('chat-open', 'body:has(.ai-chat:not(.hidden)) &');
addVariant('chat-open', 'body:has(.ai-chat[aria-expanded="true"]) &');
addVariant(
'sheet-open',
'html:has(.side-sheet[aria-modal="true"][aria-expanded="true"]) &, &:has(.side-sheet[aria-modal="true"][aria-expanded="true"])'
);
/**
* Variant when a header is displayed.
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'bun:test';
import { getContentTestURL } from './utils';
describe('llms.txt', () => {
it('should expose a llms.txt file', async () => {
const response = await fetch(
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms.txt')
);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/markdown');
expect(await response.text()).toContain('# E2E Tests GitBook Open');
});
it('should expose a llms.txt file with the accept header', async () => {
const response = await fetch(
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms.txt'),
{
headers: {
Accept: 'text/markdown',
},
}
);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/markdown');
expect(await response.text()).toContain('# E2E Tests GitBook Open');
});
});
describe('llms-full.txt', () => {
it('should expose a llms-full.txt file', async () => {
const response = await fetch(
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms-full.txt')
);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/markdown');
expect(await response.text()).toContain('# Welcome');
});
it('should expose a llms-full.txt file with the accept header', async () => {
const response = await fetch(
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms-full.txt'),
{
headers: {
Accept: 'text/markdown',
},
}
);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/markdown');
expect(await response.text()).toContain('# Welcome');
});
});
+27
View File
@@ -0,0 +1,27 @@
import { expect, it } from 'bun:test';
import { getContentTestURL } from './utils';
it('should expose a markdown page with the .md extension', async () => {
const response = await fetch(
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/text-page.md')
);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/markdown');
expect(await response.text()).toContain('# Text page');
});
it('should expose a markdown page with the accept header', async () => {
const response = await fetch(
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/text-page'),
{
headers: {
Accept: 'text/markdown',
},
}
);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/markdown');
expect(await response.text()).toContain('# Text page');
});
+3 -1
View File
@@ -71,5 +71,7 @@ export function getAssetURL(location: Partial<IconsAssetsLocation>, path: string
*/
export function getIconAssetURL(context: IconsContextType, style: string, icon: string): string {
const location = context.assetsByStyles?.[style] ?? context;
return getAssetURL(location, `svgs/${style}/${icon}.svg`);
// Ensure icon is always a string to prevent [object Object]
const iconName = typeof icon === 'string' ? icon : String(icon);
return getAssetURL(location, `svgs/${style}/${iconName}.svg`);
}
@@ -10,6 +10,7 @@ import { type OpenAPIContext, getOpenAPIClientContext } from './context';
import { generateMediaTypeExamples, generateSchemaExample } from './generateSchemaExample';
import { stringifyOpenAPI } from './stringifyOpenAPI';
import type { OpenAPIOperationData } from './types';
import { mergeHeaders } from './util/headers';
import { getDefaultServerURL } from './util/server';
import {
resolvePrefillCodePlaceholderFromSecurityScheme,
@@ -125,10 +126,9 @@ function generateCodeSamples(props: {
const mediaTypeRendererFactories = Object.entries(requestBody?.content ?? {}).map(
([mediaType, mediaTypeObject]) => {
return (generator: CodeSampleGenerator) => {
const mediaTypeHeaders = {
...genericHeaders,
const mediaTypeHeaders = mergeHeaders(genericHeaders, {
'Content-Type': mediaType,
};
});
return {
mediaType,
element: context.renderCodeBlock({
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'bun:test';
import { mergeHeaders } from './headers';
describe('mergeHeaders', () => {
it('should merge headers without conflicts', () => {
const baseHeaders = {
'user-agent': 'browser',
Authorization: 'Bearer token',
};
const overrideHeaders = {
'Content-Type': 'application/json',
};
const result = mergeHeaders(baseHeaders, overrideHeaders);
expect(result['user-agent']).toBe('browser');
expect(result.Authorization).toBe('Bearer token');
expect(result['Content-Type']).toBe('application/json');
});
it('should remove duplicate headers case-insensitively', () => {
const baseHeaders = {
'content-type': 'application/vnd.api+json',
'user-agent': 'browser',
};
const overrideHeaders = {
'Content-Type': 'application/json',
};
const result = mergeHeaders(baseHeaders, overrideHeaders);
// Should only have one Content-Type header (from override)
expect(result['Content-Type']).toBe('application/json');
expect(result['content-type']).toBeUndefined();
expect(result['user-agent']).toBe('browser');
});
it('should handle multiple case variations', () => {
const baseHeaders = {
'Content-Type': 'application/json',
'content-type': 'application/vnd.api+json',
'CONTENT-TYPE': 'text/plain',
'User-Agent': 'browser',
};
const overrideHeaders = {
'Content-Type': 'application/xml',
};
const result = mergeHeaders(baseHeaders, overrideHeaders);
// Should only have one Content-Type header (from override)
expect(result['Content-Type']).toBe('application/xml');
expect(result['content-type']).toBeUndefined();
expect(result['CONTENT-TYPE']).toBeUndefined();
expect(result['User-Agent']).toBe('browser');
});
it('should override headers with same key but different casing', () => {
const baseHeaders = {
'user-agent': 'old-browser',
'User-Agent': 'new-browser',
};
const overrideHeaders = {
'USER-AGENT': 'override-browser',
};
const result = mergeHeaders(baseHeaders, overrideHeaders);
// Should only have the override header
expect(result['USER-AGENT']).toBe('override-browser');
expect(result['user-agent']).toBeUndefined();
expect(result['User-Agent']).toBeUndefined();
});
it('should handle empty base headers', () => {
const baseHeaders = {};
const overrideHeaders = {
'Content-Type': 'application/json',
Authorization: 'Bearer token',
};
const result = mergeHeaders(baseHeaders, overrideHeaders);
expect(result['Content-Type']).toBe('application/json');
expect(result.Authorization).toBe('Bearer token');
});
it('should handle empty override headers', () => {
const baseHeaders = {
'Content-Type': 'application/json',
Authorization: 'Bearer token',
};
const overrideHeaders = {};
const result = mergeHeaders(baseHeaders, overrideHeaders);
expect(result['Content-Type']).toBe('application/json');
expect(result.Authorization).toBe('Bearer token');
});
it('should preserve override header casing', () => {
const baseHeaders = {
'content-type': 'application/json',
};
const overrideHeaders = {
'Content-Type': 'application/xml',
};
const result = mergeHeaders(baseHeaders, overrideHeaders);
// Should use the casing from override headers
expect(result['Content-Type']).toBe('application/xml');
expect(Object.keys(result)).toContain('Content-Type');
expect(Object.keys(result)).not.toContain('content-type');
});
});
@@ -0,0 +1,18 @@
/**
* Merges two header objects, removing duplicate headers case-insensitively.
* Headers from the second object take precedence over headers from the first object.
*/
export function mergeHeaders(
baseHeaders: Record<string, string>,
overrideHeaders: Record<string, string>
): Record<string, string> {
const overrideKeysLower = new Set(Object.keys(overrideHeaders).map((key) => key.toLowerCase()));
const filteredBaseHeaders = Object.fromEntries(
Object.entries(baseHeaders).filter(([key]) => !overrideKeysLower.has(key.toLowerCase()))
);
return {
...filteredBaseHeaders,
...overrideHeaders,
};
}
+34 -7
View File
@@ -8,6 +8,14 @@
set -euo pipefail
DRY_RUN=false
for arg in "$@"; do
if [[ "${arg}" == "--dry-run" ]]; then
DRY_RUN=true
break
fi
done
NAME=$(node -p "require('./package.json').name")
VERSION=$(node -p "require('./package.json').version")
@@ -16,6 +24,27 @@ if npm view "${NAME}@${VERSION}" >/dev/null 2>&1; then
exit 0
fi
# We compute the workspace root relative to this script
WORKSPACE_ROOT=$(dirname $(dirname $(realpath $0)))
# Strip development exports before packing and restore afterward.
PACKAGE_JSON_PATH="${PWD}/package.json"
PACKAGE_JSON_BACKUP=$(mktemp)
cp "${PACKAGE_JSON_PATH}" "${PACKAGE_JSON_BACKUP}"
cleanup() {
if [[ -n "${TARBALL_PATH:-}" && -f "${TARBALL_PATH}" ]]; then
rm -f "${TARBALL_PATH}"
fi
if [[ -f "${PACKAGE_JSON_BACKUP}" ]]; then
mv "${PACKAGE_JSON_BACKUP}" "${PACKAGE_JSON_PATH}"
fi
}
trap cleanup EXIT
node "${WORKSPACE_ROOT}/scripts/strip-development-exports.mjs" "${PACKAGE_JSON_PATH}"
# Sanitize the name to make it a valid filename as bun doesn't support @ in filenames
SANITIZED_NAME=${NAME//@/}
SANITIZED_NAME=${SANITIZED_NAME//\//-}
@@ -23,9 +52,6 @@ TARBALL_FILENAME="${SANITIZED_NAME}-${VERSION}.tgz"
bun pm pack --filename "${TARBALL_FILENAME}"
# We compute the workspace root relative to this script
WORKSPACE_ROOT=$(dirname $(dirname $(realpath $0)))
# Bun pack puts the tarball in the workspace root
TARBALL_PATH="${WORKSPACE_ROOT}/${TARBALL_FILENAME}"
@@ -34,8 +60,9 @@ if [[ ! -f "${TARBALL_PATH}" ]]; then
exit 1
fi
# Clean up the tarball
trap 'rm -f "${TARBALL_PATH}"' EXIT
# Publish with verbose logging to aid debugging
npm publish "${TARBALL_PATH}" --no-workspaces --provenance
if [[ "${DRY_RUN}" == "true" ]]; then
echo "npm publish \"${TARBALL_PATH}\" --no-workspaces --provenance"
else
npm publish "${TARBALL_PATH}" --no-workspaces --provenance
fi
+57
View File
@@ -0,0 +1,57 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
export function stripDevelopmentExports(exportsField) {
if (!exportsField || typeof exportsField !== 'object' || Array.isArray(exportsField)) {
return false;
}
let changed = false;
if (Object.prototype.hasOwnProperty.call(exportsField, 'development')) {
delete exportsField.development;
changed = true;
}
for (const value of Object.values(exportsField)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
if (stripDevelopmentExports(value)) {
changed = true;
}
}
}
return changed;
}
export async function stripDevelopmentExportsInFile(filePath) {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw);
if (!parsed.exports) {
return false;
}
const changed = stripDevelopmentExports(parsed.exports);
if (!changed) {
return false;
}
const next = `${JSON.stringify(parsed, null, 4)}\n`;
await fs.writeFile(filePath, next);
return true;
}
async function main() {
const filePath = process.argv[2] || path.join(process.cwd(), 'package.json');
await stripDevelopmentExportsInFile(filePath);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error('Failed to strip development exports:', error);
process.exit(1);
});
}
@@ -0,0 +1,61 @@
import { expect, test } from 'bun:test';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { stripDevelopmentExportsInFile } from './strip-development-exports.mjs';
test('stripDevelopmentExportsInFile removes development conditions', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'strip-dev-exports-'));
const filePath = path.join(dir, 'package.json');
const original = {
name: '@gitbook/example',
exports: {
'.': {
types: './dist/index.d.ts',
development: './src/index.ts',
default: './dist/index.js',
},
'./icons': {
types: './dist/icons.d.ts',
development: './src/icons.ts',
default: './dist/icons.js',
},
},
};
await fs.writeFile(filePath, `${JSON.stringify(original, null, 4)}\n`);
const changed = await stripDevelopmentExportsInFile(filePath);
expect(changed).toBe(true);
const updated = JSON.parse(await fs.readFile(filePath, 'utf8'));
expect(updated.exports['.'].development).toBeUndefined();
expect(updated.exports['./icons'].development).toBeUndefined();
expect(updated.exports['.'].default).toBe('./dist/index.js');
});
test('stripDevelopmentExportsInFile returns false when nothing to remove', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'strip-dev-exports-'));
const filePath = path.join(dir, 'package.json');
const original = {
name: '@gitbook/example',
exports: {
'.': {
types: './dist/index.d.ts',
default: './dist/index.js',
},
},
};
await fs.writeFile(filePath, `${JSON.stringify(original, null, 4)}\n`);
const changed = await stripDevelopmentExportsInFile(filePath);
expect(changed).toBe(false);
const updated = JSON.parse(await fs.readFile(filePath, 'utf8'));
expect(updated).toEqual(original);
});