mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-01 14:38:39 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4768d53f78 | |||
| f3c1565aba | |||
| 0470bdbefd |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add sidesheet component, use it for TOC and AIChat
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Set <html lang> based on translated language
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/embed": patch
|
||||
---
|
||||
|
||||
Fix signed JWT token usage
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Serve a markdown version of the page when the "Accept" header contains "text/markdown".
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Grow InlineActionButton to take available width on line
|
||||
@@ -56,7 +56,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "catalog:",
|
||||
"bun-types": "catalog:",
|
||||
"react": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
@@ -157,7 +156,6 @@
|
||||
"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",
|
||||
@@ -197,7 +195,6 @@
|
||||
"@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",
|
||||
@@ -348,7 +345,7 @@
|
||||
"react-dom": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "0.158.0",
|
||||
"@gitbook/api": "0.155.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -729,7 +726,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.158.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-oWrUY0gQ4rVKyNciHaXQrvi+EoiLCWHhAD0QyAhsHJp9+ndaLRMV6WfK2KbiGQmH8kiWwwBmwmQfMkZFmm1eyQ=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.155.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-g4UfJRnej/GZ9pORHcS9mV0kKiJLRvhMlGOeOoEVU/LSIz6s35VDqauSBs7rH00AOaMABDg7uEko8tUSA4aaww=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
@@ -1535,8 +1532,6 @@
|
||||
|
||||
"@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
@@ -41,7 +41,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.158.0",
|
||||
"@gitbook/api": "0.155.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"react": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
@@ -34,7 +33,6 @@
|
||||
"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"
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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('jwt_token', frameOptions.visitor.token);
|
||||
url.searchParams.set('token', frameOptions.visitor.token);
|
||||
}
|
||||
|
||||
if (frameOptions.visitor?.unsignedClaims) {
|
||||
|
||||
@@ -15,9 +15,7 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"types": [
|
||||
"bun-types" // add Bun global
|
||||
]
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
@@ -677,6 +677,70 @@ 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',
|
||||
|
||||
@@ -355,7 +355,6 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
url: 'https://www.gitbook.com/privacy',
|
||||
},
|
||||
socialPreview: {},
|
||||
socialAccounts: [],
|
||||
};
|
||||
|
||||
const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial));
|
||||
|
||||
@@ -77,8 +77,7 @@
|
||||
"usehooks-ts": "catalog:",
|
||||
"warn-once": "^0.1.1",
|
||||
"zod": "^3",
|
||||
"zustand": "^5.0.3",
|
||||
"negotiator": "^1.0.0"
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^5.0.9",
|
||||
@@ -95,7 +94,6 @@
|
||||
"@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",
|
||||
|
||||
+1
-2
@@ -23,8 +23,7 @@ export default async function SiteDynamicLayout({
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout
|
||||
htmlClassName="sheet-open:gutter-stable"
|
||||
bodyClassName="site-background"
|
||||
className="site-background"
|
||||
forcedTheme={forcedTheme}
|
||||
context={context}
|
||||
>
|
||||
|
||||
+1
-5
@@ -19,11 +19,7 @@ export default async function SiteStaticLayout({
|
||||
const withTracking = shouldTrackEvents();
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout
|
||||
htmlClassName="sheet-open:gutter-stable"
|
||||
bodyClassName="site-background"
|
||||
context={context}
|
||||
>
|
||||
<CustomizationRootLayout className="site-background" context={context}>
|
||||
<SiteLayout
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
|
||||
@@ -27,7 +27,6 @@ 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';
|
||||
@@ -70,22 +69,15 @@ export function AIChat() {
|
||||
}, [chat.opened, trackEvent]);
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
side="right"
|
||||
open={chat.opened}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
chatController.open();
|
||||
} else {
|
||||
chatController.close();
|
||||
}
|
||||
}}
|
||||
withOverlay={true}
|
||||
<div
|
||||
className={tcls(
|
||||
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
|
||||
'ai-chat 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'
|
||||
)}
|
||||
>
|
||||
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
|
||||
<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">
|
||||
<EmbeddableFrameMain data-testid="ai-chat">
|
||||
<EmbeddableFrameHeader>
|
||||
<AIChatDynamicIcon trademark={config.trademark} />
|
||||
@@ -115,7 +107,7 @@ export function AIChat() {
|
||||
</EmbeddableFrameBody>
|
||||
</EmbeddableFrameMain>
|
||||
</EmbeddableFrame>
|
||||
</SideSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -226,8 +218,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"
|
||||
trailing={{ fade: false, button: true }}
|
||||
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
|
||||
fadeEdges={['leading']}
|
||||
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-50',
|
||||
'z-10',
|
||||
'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-[max(env(safe-area-inset-bottom),1rem)]',
|
||||
'right-[max(env(safe-area-inset-right),1rem)]',
|
||||
'left-[max(env(safe-area-inset-left),4rem)]',
|
||||
'bottom-4',
|
||||
'right-4',
|
||||
'left-16',
|
||||
'max-w-md',
|
||||
'text-balance',
|
||||
'sm:left-auto',
|
||||
|
||||
@@ -96,6 +96,36 @@ 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 grow"
|
||||
className="inline-flex max-w-full leading-normal [transition-property:translate,opacity,box-shadow,background,border]"
|
||||
submitButton={{
|
||||
label: tString(language, action === 'ask' ? 'send' : 'search'),
|
||||
}}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 = () => {
|
||||
@@ -36,7 +37,9 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
|
||||
|
||||
const inlineElement = (
|
||||
// Set the leading to have some vertical space between adjacent buttons
|
||||
<ButtonImplementation />
|
||||
<span className="inline-button leading-12 [&:has(+.inline-button)]:mr-2">
|
||||
<ButtonImplementation />
|
||||
</span>
|
||||
);
|
||||
|
||||
return inlineElement;
|
||||
|
||||
@@ -9,12 +9,8 @@ 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(inlineButtonStyle, style, getTextAlignment(block.data?.align))}>
|
||||
<p className={tcls(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', 'justify-start'];
|
||||
return ['text-start', 'self-start'];
|
||||
case TextAlignment.Center:
|
||||
return ['text-center', 'self-center', 'justify-center'];
|
||||
return ['text-center', 'self-center'];
|
||||
case TextAlignment.End:
|
||||
return ['text-end', 'self-end', 'justify-end'];
|
||||
return ['text-end', 'self-end'];
|
||||
default:
|
||||
return nullIfNever(textAlignment);
|
||||
}
|
||||
|
||||
@@ -79,10 +79,9 @@ export async function EmbeddableDocsPage(
|
||||
orientation="vertical"
|
||||
className="not-hydrated:animate-blur-in-slow"
|
||||
contentClassName="p-4"
|
||||
leading={{ fade: !context.sections, button: true }}
|
||||
trailing={{ fade: false, button: true }}
|
||||
fadeEdges={context.sections ? [] : ['leading']}
|
||||
>
|
||||
<TableOfContents context={context} withTrademark={false} />
|
||||
<TableOfContents className="pt-0" context={context} />
|
||||
<PageBody
|
||||
context={context}
|
||||
page={page}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitbook/embed';
|
||||
import { createChannel } from 'bidc';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { useAI, useAIChatController } from '@/components/AI';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
@@ -19,12 +19,6 @@ 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.
|
||||
*/
|
||||
@@ -36,32 +30,24 @@ 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`);
|
||||
});
|
||||
}, [chatController]);
|
||||
}, [router, baseURL, chatController]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (window.parent === window) {
|
||||
return;
|
||||
}
|
||||
|
||||
log('[gitbook] create channel with parent window');
|
||||
console.log('[gitbook] create channel with parent window');
|
||||
const channel = createChannel();
|
||||
|
||||
channel.receive((payload) => {
|
||||
const { baseURL, router, chatController } = refs.current;
|
||||
const message = payload as ParentToFrameMessage;
|
||||
|
||||
log('[gitbook] received message', message);
|
||||
console.log('[gitbook] received message', message);
|
||||
|
||||
switch (message.type) {
|
||||
case 'clearChat': {
|
||||
@@ -93,10 +79,10 @@ export function EmbeddableIframeAPI(props: {
|
||||
});
|
||||
|
||||
return () => {
|
||||
log('[gitbook] cleanup');
|
||||
console.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 { Trademark } from '../TableOfContents/Trademark';
|
||||
import { TrademarkLink } 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 ? (
|
||||
<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"
|
||||
<TrademarkLink
|
||||
className="border-tint-solid/3 border-t bg-tint-solid/1 px-4 py-2.5 text-tint/8 ring-0"
|
||||
context={context}
|
||||
placement={SiteInsightsTrademarkPlacement.Embed}
|
||||
/>
|
||||
|
||||
@@ -42,7 +42,6 @@ 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,13 +1,16 @@
|
||||
'use client';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef, useState } 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.
|
||||
*/
|
||||
@@ -15,6 +18,25 @@ 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(() => {
|
||||
@@ -28,10 +50,8 @@ export function HeaderMobileMenu(props: ButtonProps) {
|
||||
iconOnly
|
||||
variant="blank"
|
||||
label={tString(language, 'table_of_contents_button_label')}
|
||||
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.
|
||||
onClick={toggleNavigation}
|
||||
active={isOpen}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ interface VariantSpace {
|
||||
*/
|
||||
function useVariantSpaceHref(variantSpace: VariantSpace, currentSpacePath: string, active = false) {
|
||||
const currentPathname = useCurrentPagePath();
|
||||
const { metaLinks } = useCurrentPageMetadata();
|
||||
const { metaLinks, currentPage } = 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,6 +27,17 @@ 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.
|
||||
@@ -44,9 +55,18 @@ 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 { Trademark } from '@/components/TableOfContents/Trademark';
|
||||
import { TrademarkLink } 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 ? (
|
||||
<Trademark
|
||||
<TrademarkLink
|
||||
context={context}
|
||||
placement={SiteInsightsTrademarkPlacement.Pdf}
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,10 @@ 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
|
||||
@@ -32,21 +36,6 @@ 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(
|
||||
@@ -60,7 +49,7 @@ export async function PageHeader(props: {
|
||||
hasAncestors ? 'page-has-ancestors' : 'page-no-ancestors'
|
||||
)}
|
||||
>
|
||||
{hasPageActions ? (
|
||||
{page.layout.tableOfContents && 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, getSpaceLocale } from '@/intl/server';
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import { getAssetURL } from '@/lib/assets';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -56,19 +56,16 @@ 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. */
|
||||
bodyClassName?: string;
|
||||
className?: string;
|
||||
forcedTheme?: CustomizationThemeMode | null;
|
||||
context: GitBookAnyContext;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { htmlClassName, bodyClassName, context, forcedTheme, children } = props;
|
||||
const { className, 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);
|
||||
@@ -91,7 +88,7 @@ export async function CustomizationRootLayout(props: {
|
||||
return (
|
||||
<html
|
||||
suppressHydrationWarning
|
||||
lang={locale}
|
||||
lang={customization.internationalization.locale}
|
||||
className={tcls(
|
||||
customization.styling.corners && `${customization.styling.corners}-corners`,
|
||||
'theme' in customization.styling && `theme-${customization.styling.theme}`,
|
||||
@@ -109,8 +106,7 @@ 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>
|
||||
@@ -182,7 +178,7 @@ export async function CustomizationRootLayout(props: {
|
||||
}
|
||||
`}</style>
|
||||
</head>
|
||||
<body className={tcls(bodyClassName, 'sheet-open:overflow-hidden')}>
|
||||
<body className={className}>
|
||||
<IconsProvider
|
||||
assetsURL={GITBOOK_ICONS_URL}
|
||||
assetsURLToken={GITBOOK_ICONS_TOKEN}
|
||||
|
||||
@@ -101,7 +101,6 @@ export async function generateSiteLayoutViewport(context: GitBookSiteContext): P
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
viewportFit: 'cover',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,13 @@ 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
|
||||
@@ -21,7 +26,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 });
|
||||
useRegisterPageMetadata({ pageMetaLinks, currentPage });
|
||||
|
||||
useStripFallbackQueryParam();
|
||||
return null;
|
||||
@@ -44,6 +49,8 @@ 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]);
|
||||
@@ -54,9 +61,13 @@ function useStripFallbackQueryParam() {
|
||||
*/
|
||||
function useRegisterPageMetadata(metadata: {
|
||||
pageMetaLinks: PageMetaLinks | null;
|
||||
currentPage: {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
} | null;
|
||||
}) {
|
||||
const { pageMetaLinks } = metadata;
|
||||
const { pageMetaLinks, currentPage } = metadata;
|
||||
React.useEffect(() => {
|
||||
currentPageMetadataStore.setState({ metaLinks: pageMetaLinks });
|
||||
}, [pageMetaLinks]);
|
||||
currentPageMetadataStore.setState({ metaLinks: pageMetaLinks, currentPage });
|
||||
}, [pageMetaLinks, currentPage]);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ 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;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -66,7 +71,6 @@ 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.
|
||||
@@ -102,7 +106,10 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
|
||||
staticRoute={props.staticRoute}
|
||||
/>
|
||||
</div>
|
||||
<PageClientLayout pageMetaLinks={pageMetaLinks} />
|
||||
<PageClientLayout
|
||||
pageMetaLinks={pageMetaLinks}
|
||||
currentPage={{ id: page.id, spaceId: context.space.id }}
|
||||
/>
|
||||
</div>
|
||||
</PageContextProvider>
|
||||
);
|
||||
@@ -151,7 +158,7 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
|
||||
});
|
||||
|
||||
if (!pageTarget) {
|
||||
if (context.isFallback) {
|
||||
if (context.fallback.isFallback) {
|
||||
redirect(context.linker.toPathInSpace('/'));
|
||||
}
|
||||
notFound();
|
||||
@@ -246,12 +253,13 @@ 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.isFallback) {
|
||||
if (context.fallback.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,
|
||||
@@ -337,6 +345,7 @@ async function resolvePageMetaLinks(
|
||||
space: resolved?.space
|
||||
? { id: resolved.space.id, language: resolved.space.language }
|
||||
: null,
|
||||
pageID: resolved?.page?.id,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -348,7 +357,13 @@ async function resolvePageMetaLinks(
|
||||
return {
|
||||
canonical: resolvedCanonical ?? null,
|
||||
alternates: resolvedAlternates.filter(
|
||||
(alt): alt is { href: string; space: AlternateLinkSpace | null } => !!alt.href
|
||||
(
|
||||
alt
|
||||
): alt is {
|
||||
href: string;
|
||||
space: AlternateLinkSpace | null;
|
||||
pageID: string | undefined;
|
||||
} => !!alt.href
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { getDataOrNull } from '@/lib/data';
|
||||
import { resolvePageId, resolvePagePath } from '@/lib/pages';
|
||||
import { resolveFallbackPage, resolvePageId, resolvePagePath } from '@/lib/pages';
|
||||
import { withLeadingSlash } from '@/lib/paths';
|
||||
|
||||
export interface PagePathParams {
|
||||
@@ -36,7 +36,8 @@ 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 } = context;
|
||||
const { organizationId, site, space, revision, shareKey, linker, revisionId, fallback } =
|
||||
context;
|
||||
|
||||
if ('pageId' in params) {
|
||||
return resolvePageId(revision.pages, params.pageId);
|
||||
@@ -100,6 +101,9 @@ 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,12 +76,8 @@ 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}`}
|
||||
trailing={{
|
||||
fade: true,
|
||||
button: true,
|
||||
className: children ? 'after:border-tint' : '',
|
||||
}}
|
||||
active={currentSection.id}
|
||||
trailingEdgeScrollClassName={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',
|
||||
'transition-[max-width] duration-300',
|
||||
'hydrated:transition-[max-width] duration-300',
|
||||
|
||||
// Ensure the footer is display below the viewport even if the content is not enough
|
||||
withFooter && [
|
||||
@@ -141,82 +141,79 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
<TableOfContents
|
||||
context={context}
|
||||
header={
|
||||
<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>
|
||||
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>
|
||||
)
|
||||
}
|
||||
// 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 || 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
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{variants.generic.length > 1 ? (
|
||||
<SpacesDropdown
|
||||
context={context}
|
||||
<>
|
||||
{!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={variants.generic}
|
||||
className="w-full px-3"
|
||||
siteSpaces={visibleSiteSpaces}
|
||||
className="max-lg:hidden"
|
||||
viewport="desktop"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null
|
||||
</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}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{children}
|
||||
|
||||
@@ -32,8 +32,7 @@ export function PageDocumentItem(props: { page: ClientTOCPageDocument }) {
|
||||
'my-2',
|
||||
'border-tint-subtle',
|
||||
'sidebar-list-default:border-l',
|
||||
'sidebar-list-line:border-l',
|
||||
'break-anywhere'
|
||||
'sidebar-list-line:border-l'
|
||||
)}
|
||||
/>
|
||||
) : null
|
||||
|
||||
@@ -14,9 +14,9 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
|
||||
<li className="flex flex-col">
|
||||
<div
|
||||
className={tcls(
|
||||
'-top-4 sticky z-1 flex items-center gap-3 px-3',
|
||||
'-top-6 sticky z-1 flex items-center gap-3 px-3 pt-6',
|
||||
'font-semibold text-xs uppercase tracking-wide',
|
||||
'mt-2 pt-4 pb-3', // Add extra padding to make the header fade a bit nicer
|
||||
'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',
|
||||
'lg:[html.sidebar-default.theme-gradient_&]:bg-gradient-primary',
|
||||
'lg:[html.sidebar-default.theme-gradient.tint_&]:bg-gradient-tint',
|
||||
isFirst ? '-mt-4' : ''
|
||||
'[html.sidebar-default.theme-gradient_&]:bg-gradient-primary',
|
||||
'[html.sidebar-default.theme-gradient.tint_&]:bg-gradient-tint',
|
||||
isFirst ? '-mt-6' : ''
|
||||
)}
|
||||
>
|
||||
<TOCPageIcon page={page} />
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'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,59 +3,42 @@ 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, withTrademark = true } = props;
|
||||
const { innerHeader, context, header, className } = props;
|
||||
const { customization, revision } = context;
|
||||
|
||||
const pages = await encodeClientTableOfContents(context, revision.pages, revision.pages);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideSheet
|
||||
side="left"
|
||||
<aside // Sidebar container, responsible for setting the right dimensions and position for the sidebar.
|
||||
data-testid="table-of-contents"
|
||||
id="table-of-contents"
|
||||
toggleClass="navigation-open"
|
||||
withOverlay={true}
|
||||
withCloseButton={true}
|
||||
className={tcls(
|
||||
'group/table-of-contents',
|
||||
'group',
|
||||
'text-sm',
|
||||
|
||||
'grow-0',
|
||||
'shrink-0',
|
||||
|
||||
'w-4/5',
|
||||
'md:w-1/2',
|
||||
'lg:w-72',
|
||||
'basis-72',
|
||||
'basis-full',
|
||||
'lg:basis-72',
|
||||
'lg:page-no-toc:basis-56',
|
||||
|
||||
'max-lg:not-sidebar-filled:bg-tint-base',
|
||||
'max-lg:not-sidebar-filled:border-r',
|
||||
'border-tint-subtle',
|
||||
|
||||
'lg:flex!',
|
||||
'lg:animate-none!',
|
||||
'relative',
|
||||
'z-1',
|
||||
'lg:sticky',
|
||||
'lg:mr-12',
|
||||
'lg:z-0!',
|
||||
|
||||
// Server-side static positioning
|
||||
'lg:top-0',
|
||||
@@ -77,22 +60,30 @@ 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-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
|
||||
'pt-4',
|
||||
'pb-4',
|
||||
'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(
|
||||
'-ms-5',
|
||||
'relative flex min-h-0 grow flex-col border-tint-subtle',
|
||||
'lg:-ms-5',
|
||||
'relative flex grow flex-col overflow-hidden border-tint-subtle',
|
||||
|
||||
'sidebar-filled:bg-tint-subtle',
|
||||
'theme-muted:bg-tint-subtle',
|
||||
@@ -100,43 +91,40 @@ 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-2xl',
|
||||
'sidebar-filled:rounded-xl',
|
||||
'straight-corners:rounded-none',
|
||||
'page-has-toc:[html.sidebar-filled.circular-corners_&]:rounded-4xl'
|
||||
'page-has-toc:[html.sidebar-filled.circular-corners_&]:rounded-3xl'
|
||||
)}
|
||||
>
|
||||
{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',
|
||||
},
|
||||
}}
|
||||
{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'
|
||||
)}
|
||||
>
|
||||
<PagesList
|
||||
pages={pages}
|
||||
isRoot={true}
|
||||
style="page-no-toc:hidden grow border-tint-subtle sidebar-list-line:border-l"
|
||||
style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l"
|
||||
/>
|
||||
</ScrollContainer>
|
||||
{withTrademark && customization.trademark.enabled ? (
|
||||
<Trademark
|
||||
context={context}
|
||||
placement={SiteInsightsTrademarkPlacement.Sidebar}
|
||||
className="m-2 mt-auto sidebar-default:mr-4"
|
||||
/>
|
||||
) : null}
|
||||
{customization.trademark.enabled ? (
|
||||
<Trademark
|
||||
context={context}
|
||||
placement={SiteInsightsTrademarkPlacement.Sidebar}
|
||||
/>
|
||||
) : null}
|
||||
</TOCScrollContainer>
|
||||
</div>
|
||||
</SideSheet>
|
||||
</aside>
|
||||
<TableOfContentsScript />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ 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.
|
||||
@@ -75,6 +76,8 @@ 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) {
|
||||
@@ -85,7 +88,7 @@ function LinkItem(
|
||||
|
||||
return (
|
||||
<Link
|
||||
data-active={isActive}
|
||||
ref={anchorRef}
|
||||
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, tString } from '@/intl/server';
|
||||
import { getSpaceLanguage, t } from '@/intl/server';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { GitBookSpaceContext } from '@/lib/context';
|
||||
import { Button } from '../primitives';
|
||||
import { Link } from '../primitives';
|
||||
|
||||
/**
|
||||
* Trademark link to the GitBook.
|
||||
@@ -14,6 +14,69 @@ 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;
|
||||
@@ -25,10 +88,8 @@ export function Trademark(props: {
|
||||
url.searchParams.set('utm_campaign', space.id);
|
||||
|
||||
return (
|
||||
<Button
|
||||
<Link
|
||||
target="_blank"
|
||||
variant="secondary"
|
||||
size="large"
|
||||
href={url.toString()}
|
||||
className={tcls(
|
||||
'text-sm',
|
||||
@@ -40,21 +101,30 @@ export function Trademark(props: {
|
||||
'items-center',
|
||||
'px-5',
|
||||
'py-4',
|
||||
'gap-3',
|
||||
'whitespace-normal',
|
||||
|
||||
'bg-transparent',
|
||||
'depth-subtle:shadow-none',
|
||||
'border-tint-subtle',
|
||||
'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',
|
||||
|
||||
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,3 +1,4 @@
|
||||
export { TableOfContents } from './TableOfContents';
|
||||
export { PagesList } from './PagesList';
|
||||
export { TOCScrollContainer } from './TOCScroller';
|
||||
export { Trademark } from './Trademark';
|
||||
|
||||
@@ -11,16 +11,18 @@ 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() {
|
||||
const metaLinks = zustand.useStore(currentPageMetadataStore, (state) => state.metaLinks);
|
||||
return {
|
||||
metaLinks,
|
||||
};
|
||||
return zustand.useStore(currentPageMetadataStore, (state) => state);
|
||||
}
|
||||
|
||||
@@ -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 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)]',
|
||||
'px-4',
|
||||
'sm:px-6',
|
||||
'md:px-8',
|
||||
'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-50 w-screen max-w-md animate-scale-in px-4 data-[state='closed']:animate-scale-out sm:w-auto"
|
||||
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"
|
||||
>
|
||||
<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, type ButtonProps } from './Button';
|
||||
import { Button } from './Button';
|
||||
|
||||
/**
|
||||
* A container that encapsulates a scrollable area with usability features.
|
||||
@@ -17,26 +17,17 @@ 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';
|
||||
|
||||
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;
|
||||
};
|
||||
/** Whether to fade out the edges of the container. */
|
||||
fadeEdges?: ('leading' | 'trailing')[];
|
||||
|
||||
/** The ID or ref of the active item to scroll to. */
|
||||
active?: string | React.RefObject<HTMLElement | null>;
|
||||
@@ -48,9 +39,10 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
className,
|
||||
contentClassName,
|
||||
orientation,
|
||||
fadeEdges = ['leading', 'trailing'],
|
||||
active,
|
||||
leading = { fade: true, button: true },
|
||||
trailing = { fade: true, button: true },
|
||||
leadingEdgeScrollClassName,
|
||||
trailingEdgeScrollClassName,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
@@ -110,9 +102,7 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
return;
|
||||
}
|
||||
const activeItem =
|
||||
typeof active === 'string'
|
||||
? containerRef.current?.querySelector(active)
|
||||
: active.current;
|
||||
typeof active === 'string' ? document.getElementById(active) : active.current;
|
||||
if (!activeItem || !container.contains(activeItem)) {
|
||||
return;
|
||||
}
|
||||
@@ -148,30 +138,28 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'group/scroll-container relative flex shrink grow',
|
||||
orientation === 'horizontal' ? 'min-w-0' : 'min-h-0',
|
||||
'group/scroll-container relative flex overflow-hidden',
|
||||
className,
|
||||
scrollPosition > 0 ? leading?.className : '',
|
||||
scrollPosition < scrollSize ? trailing?.className : ''
|
||||
scrollPosition > 0 ? leadingEdgeScrollClassName : '',
|
||||
scrollPosition < scrollSize ? trailingEdgeScrollClassName : ''
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
className={tcls(
|
||||
'flex flex-1 overflow-hidden',
|
||||
orientation === 'horizontal' ? 'min-w-0' : 'min-h-0',
|
||||
'flex shrink grow',
|
||||
orientation === 'horizontal' ? 'no-scrollbar' : 'hide-scrollbar',
|
||||
orientation === 'horizontal' ? 'overflow-x-scroll' : 'flex-col overflow-y-auto',
|
||||
leading.fade && scrollPosition > 0
|
||||
fadeEdges.includes('leading') && scrollPosition > 0
|
||||
? orientation === 'horizontal'
|
||||
? 'mask-l-from-[calc(100%-1rem)]'
|
||||
: 'mask-t-from-[calc(100%-1rem)]'
|
||||
? 'mask-l-from-[calc(100%-2rem)]'
|
||||
: 'mask-t-from-[calc(100%-2rem)]'
|
||||
: '',
|
||||
trailing.fade && scrollPosition < scrollSize
|
||||
fadeEdges.includes('trailing') && scrollPosition < scrollSize
|
||||
? orientation === 'horizontal'
|
||||
? 'mask-r-from-[calc(100%-1rem)]'
|
||||
: 'mask-b-from-[calc(100%-1rem)]'
|
||||
? 'mask-r-from-[calc(100%-2rem)]'
|
||||
: 'mask-b-from-[calc(100%-2rem)]'
|
||||
: '',
|
||||
contentClassName
|
||||
)}
|
||||
@@ -181,52 +169,44 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
</div>
|
||||
|
||||
{/* Scroll buttons back & forward */}
|
||||
{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}
|
||||
<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')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -234,7 +214,7 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
/**
|
||||
* Scroll to an element in a container.
|
||||
*/
|
||||
function scrollToElementInContainer(element: Element, container: HTMLElement) {
|
||||
function scrollToElementInContainer(element: HTMLElement, container: HTMLElement) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
@@ -249,8 +229,6 @@ function scrollToElementInContainer(element: Element, container: HTMLElement) {
|
||||
(rect.left - containerRect.left) -
|
||||
container.clientWidth / 2 +
|
||||
rect.width / 2,
|
||||
// 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',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
'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,11 +33,7 @@ export type ImageResponsiveSize = {
|
||||
width: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Maximum device pixel ratio (DPR) to generate in srcSet.
|
||||
* Limit to 3 to align with routes/image.ts
|
||||
*/
|
||||
const MAX_DPR = 3;
|
||||
const MAX_DPR = 4;
|
||||
|
||||
interface ImageCommonProps {
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,25 @@ 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.
|
||||
@@ -52,11 +71,10 @@ export type SiteURLData = Pick<
|
||||
imagesContextId: string;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Necessary data to properly handle fallback rendering.
|
||||
* This is used when switching between variants to avoid fetching every revision every time.
|
||||
*/
|
||||
isFallback?: boolean;
|
||||
fallback?: FallbackData;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -142,7 +160,7 @@ export type GitBookSiteContext = GitBookSpaceContext & {
|
||||
contextId?: string;
|
||||
|
||||
/** Whether this request is a fallback rendering. */
|
||||
isFallback: boolean;
|
||||
fallback: FallbackData;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -221,7 +239,7 @@ export async function fetchSiteContextByURLLookup(
|
||||
changeRequest: data.changeRequest,
|
||||
revision: data.revision,
|
||||
contextId: data.contextId,
|
||||
isFallback: data.isFallback ?? false,
|
||||
fallback: data.fallback ?? { isFallback: false },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -240,7 +258,7 @@ export async function fetchSiteContextByIds(
|
||||
changeRequest: string | undefined;
|
||||
revision: string | undefined;
|
||||
contextId?: string;
|
||||
isFallback: boolean;
|
||||
fallback: FallbackData;
|
||||
}
|
||||
): Promise<GitBookSiteContext> {
|
||||
const { dataFetcher } = baseContext;
|
||||
@@ -352,7 +370,7 @@ export async function fetchSiteContextByIds(
|
||||
visibleSections,
|
||||
scripts,
|
||||
contextId: ids.contextId,
|
||||
isFallback: ids.isFallback,
|
||||
fallback: ids.fallback,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,57 @@ 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.
|
||||
|
||||
@@ -76,7 +76,6 @@ export function defaultCustomization(): api.SiteCustomizationSettings {
|
||||
url: 'https://www.gitbook.com/privacy',
|
||||
},
|
||||
socialPreview: {},
|
||||
socialAccounts: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CustomizationThemeMode } from '@gitbook/api';
|
||||
import Negotiator from 'negotiator';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import rison from 'rison';
|
||||
@@ -283,6 +282,13 @@ 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 = {
|
||||
@@ -299,7 +305,11 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
apiToken: siteURLData.apiToken,
|
||||
imagesContextId: imagesContextId,
|
||||
contextId: siteURLData.contextId,
|
||||
isFallback: requestURL.searchParams.get('fallback') === 'true' ? true : undefined,
|
||||
fallback: {
|
||||
isFallback: requestURL.searchParams.get('fallback') === 'true',
|
||||
...(fallbackPageID ? { pageID: fallbackPageID } : {}),
|
||||
...(fallbackSpaceID ? { spaceID: fallbackSpaceID } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
@@ -361,8 +371,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
|
||||
const siteURLWithoutProtocol = `${siteCanonicalURL.host}${siteURLData.basePath}`;
|
||||
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(
|
||||
siteURLData.pathname,
|
||||
request
|
||||
siteURLData.pathname
|
||||
);
|
||||
routeType = routeTypeFromPathname ?? routeType;
|
||||
|
||||
@@ -571,10 +580,7 @@ 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,
|
||||
request: Request
|
||||
): {
|
||||
function encodePathInSiteContent(rawPathname: string): {
|
||||
pathname: string;
|
||||
routeType?: 'static' | 'dynamic';
|
||||
} {
|
||||
@@ -593,6 +599,16 @@ function encodePathInSiteContent(
|
||||
};
|
||||
}
|
||||
|
||||
// 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' };
|
||||
@@ -613,7 +629,6 @@ function encodePathInSiteContent(
|
||||
return { pathname };
|
||||
case '~gitbook/mcp':
|
||||
case 'llms.txt':
|
||||
case 'llms-full.txt':
|
||||
case 'sitemap.xml':
|
||||
case 'sitemap-pages.xml':
|
||||
case 'robots.txt':
|
||||
@@ -625,19 +640,8 @@ function encodePathInSiteContent(
|
||||
case '~gitbook/pdf':
|
||||
// PDF routes are always dynamic as they depend on the search params.
|
||||
return { pathname, routeType: 'dynamic' };
|
||||
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',
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { pathname: encodePagePath(pathname) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,16 +677,3 @@ 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));
|
||||
}
|
||||
|
||||
@@ -327,10 +327,10 @@ const config: Config = {
|
||||
blurOut: 'blurOut 200ms ease-in both',
|
||||
'blurOut-slow': 'blurOut 500ms ease-in 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',
|
||||
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',
|
||||
|
||||
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: 'inherit' },
|
||||
to: { opacity: '1', transform: 'translateX(0)', display: 'block' },
|
||||
},
|
||||
enterFromLeft: {
|
||||
from: { opacity: '0', transform: 'translateX(-50%)', display: 'none' },
|
||||
to: { opacity: '1', transform: 'translateX(0)', display: 'inherit' },
|
||||
to: { opacity: '1', transform: 'translateX(0)', display: 'block' },
|
||||
},
|
||||
exitToRight: {
|
||||
from: { opacity: '1', transform: 'translateX(0)', display: 'inherit' },
|
||||
from: { opacity: '1', transform: 'translateX(0)', display: 'block' },
|
||||
to: { opacity: '0', transform: 'translateX(50%)', display: 'none' },
|
||||
},
|
||||
exitToLeft: {
|
||||
from: { opacity: '1', transform: 'translateX(0)', display: 'inherit' },
|
||||
from: { opacity: '1', transform: 'translateX(0)', display: 'block' },
|
||||
to: { opacity: '0', transform: 'translateX(-50%)', display: 'none' },
|
||||
},
|
||||
scaleIn: {
|
||||
@@ -591,11 +591,7 @@ 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[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"])'
|
||||
);
|
||||
addVariant('chat-open', 'body:has(.ai-chat:not(.hidden)) &');
|
||||
|
||||
/**
|
||||
* Variant when a header is displayed.
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
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');
|
||||
});
|
||||
@@ -71,7 +71,5 @@ export function getAssetURL(location: Partial<IconsAssetsLocation>, path: string
|
||||
*/
|
||||
export function getIconAssetURL(context: IconsContextType, style: string, icon: string): string {
|
||||
const location = context.assetsByStyles?.[style] ?? context;
|
||||
// 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`);
|
||||
return getAssetURL(location, `svgs/${style}/${icon}.svg`);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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,
|
||||
@@ -126,9 +125,10 @@ function generateCodeSamples(props: {
|
||||
const mediaTypeRendererFactories = Object.entries(requestBody?.content ?? {}).map(
|
||||
([mediaType, mediaTypeObject]) => {
|
||||
return (generator: CodeSampleGenerator) => {
|
||||
const mediaTypeHeaders = mergeHeaders(genericHeaders, {
|
||||
const mediaTypeHeaders = {
|
||||
...genericHeaders,
|
||||
'Content-Type': mediaType,
|
||||
});
|
||||
};
|
||||
return {
|
||||
mediaType,
|
||||
element: context.renderCodeBlock({
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -8,14 +8,6 @@
|
||||
|
||||
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")
|
||||
|
||||
@@ -24,27 +16,6 @@ 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//\//-}
|
||||
@@ -52,6 +23,9 @@ 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}"
|
||||
|
||||
@@ -60,9 +34,8 @@ 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
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
echo "npm publish \"${TARBALL_PATH}\" --no-workspaces --provenance"
|
||||
else
|
||||
npm publish "${TARBALL_PATH}" --no-workspaces --provenance
|
||||
fi
|
||||
npm publish "${TARBALL_PATH}" --no-workspaces --provenance
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user