Compare commits

...

7 Commits

Author SHA1 Message Date
Zeno Kapitein 38226b9413 Simplify AI Chat
- Add new `note` field
- Always clear `messages` when posting a message
- Remove follow-up questions
2025-09-16 16:29:10 +02:00
Zeno Kapitein b932e4e2e9 Scope search across sections and variants (#3640)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-16 12:07:29 +02:00
Samy Pessé ca4a3e8c9a Track utm on links from the toolbar (#3653) 2025-09-15 16:51:31 +02:00
John Cashman b498521196 Refactor robots.txt generation logic (#3621)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-09-15 14:37:02 +02:00
Samy Pessé 8a8cf2025d Skip using __session cookie if it's in the old format (#3652) 2025-09-15 12:10:51 +02:00
Samy Pessé ed7d47d9b8 Toolbar for authenticated GitBook users (#3650)
Co-authored-by: Viktor Renkema <vrenkema@gmail.com>
2025-09-15 10:32:23 +02:00
Viktor Renkema b5494cc04b Restyle the toolbar in CR and revision preview links (#3631)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-09-15 08:33:23 +02:00
48 changed files with 1990 additions and 555 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Simplify AI Chat
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix robots.txt preventing favicon from being indexed.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Show a toolbar for authenticated users with access to the current site.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Scope search across sections and variants
+16 -5
View File
@@ -64,7 +64,7 @@
},
"packages/expr": {
"name": "@gitbook/expr",
"version": "1.1.0",
"version": "1.2.0",
"dependencies": {
"acorn": "^8.14.0",
"acorn-loose": "8.4.0",
@@ -95,7 +95,7 @@
},
"packages/gitbook": {
"name": "gitbook",
"version": "0.17.1",
"version": "0.18.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/browser-types": "workspace:*",
@@ -142,6 +142,7 @@
"memoizee": "^0.4.17",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.12",
"next": "15.4.0",
"next-themes": "^0.2.1",
"nuqs": "^2.2.3",
@@ -197,7 +198,7 @@
},
"packages/icons": {
"name": "@gitbook/icons",
"version": "0.3.0",
"version": "0.3.1",
"bin": {
"gitbook-icons": "./bin/gitbook-icons.js",
},
@@ -233,7 +234,7 @@
},
"packages/react-contentkit": {
"name": "@gitbook/react-contentkit",
"version": "0.7.4",
"version": "0.7.5",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:*",
@@ -265,7 +266,7 @@
},
"packages/react-openapi": {
"name": "@gitbook/react-openapi",
"version": "1.4.1",
"version": "1.4.3",
"dependencies": {
"@gitbook/expr": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
@@ -2432,6 +2433,12 @@
"mnemonist": ["mnemonist@0.38.3", "", { "dependencies": { "obliterator": "^1.6.1" } }, "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw=="],
"motion": ["motion@12.23.12", "", { "dependencies": { "framer-motion": "^12.23.12", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng=="],
"motion-dom": ["motion-dom@12.23.12", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw=="],
"motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -4352,6 +4359,10 @@
"minizlib/minipass": ["minipass@2.9.0", "", { "dependencies": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" } }, "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg=="],
"motion/framer-motion": ["framer-motion@12.23.12", "", { "dependencies": { "motion-dom": "^12.23.12", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg=="],
"motion/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"p-filter/p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="],
+1
View File
@@ -46,6 +46,7 @@
"memoizee": "^0.4.17",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.12",
"next": "15.4.0",
"next-themes": "^0.2.1",
"nuqs": "^2.2.3",
+1 -1
View File
@@ -137,7 +137,7 @@ export function useAI(): AIContext {
setSearchState((prev) => ({
ask: null, // Reset ask as we assume the assistant will handle it
query: prev?.query ?? null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'default',
open: false,
}));
assistant.open(query);
@@ -21,6 +21,7 @@ export type AIChatMessage = {
role: AIMessageRole;
content: React.ReactNode;
query?: string;
note?: string;
};
export type AIChatPendingTool = {
@@ -146,7 +147,7 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
}, [setSearchState]);
@@ -159,7 +160,7 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({
ask: null,
query: prev?.query ?? null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'default',
open: false,
}));
}, [setSearchState]);
@@ -374,7 +375,7 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({
ask: input.message,
query: prev?.query ?? null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'default',
open: false,
}));
}
@@ -391,11 +392,11 @@ export function AIChatProvider(props: {
return {
...state,
messages: [
...state.messages,
{
role: AIMessageRole.User,
content: input.message,
query: input.message,
note: state.messages.length > 0 ? 'Starting a new chat' : undefined,
},
],
query: input.message,
@@ -435,7 +436,7 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({
ask: '',
query: prev?.query ?? null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'default',
open: false,
}));
}, [setSearchState]);
@@ -6,14 +6,13 @@ import type React from 'react';
import type { AIChatController, AIChatState } from '../AI';
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
import { AIResponseFeedback } from './AIResponseFeedback';
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
export function AIChatMessages(props: {
chat: AIChatState;
chatController: AIChatController;
lastUserMessageRef?: React.RefObject<HTMLDivElement>;
}) {
const { chat, chatController, lastUserMessageRef } = props;
const { chat, lastUserMessageRef } = props;
return (
<>
@@ -24,57 +23,63 @@ export function AIChatMessages(props: {
index === chat.messages.map((m) => m.role).lastIndexOf(AIMessageRole.User);
return (
<div
ref={isLastUserMessage ? lastUserMessageRef : undefined}
data-testid="ai-chat-message"
className={tcls(
message.content ? 'animate-fade-in-slow' : '',
'shrink-0',
'last:min-h-[calc(100%-5rem)]',
'scroll-mt-36',
'lg:scroll-mt-0',
'flex flex-col gap-6',
'break-words',
'group/message',
message.role === AIMessageRole.User
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
: 'text-tint-strong'
)}
style={{
animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
}}
key={index}
>
{message.content ? message.content : null}
{isLastMessage && chat.loading ? (
<div className="flex w-full animate-fade-in-slow flex-col gap-2">
{!message.content ? <HoldMessage /> : null}
<LoadingSkeleton />
<>
{message.note ? (
<div
key={`${index}-note`}
className="self-center text-tint-subtle text-xs"
>
{message.note}
</div>
) : null}
<div
ref={isLastUserMessage ? lastUserMessageRef : undefined}
data-testid="ai-chat-message"
className={tcls(
message.content ? 'animate-fade-in-slow' : '',
'shrink-0',
'last:min-h-[calc(100%-5rem)]',
'scroll-mt-36',
'lg:scroll-mt-0',
'flex flex-col gap-6',
'break-words',
'group/message',
message.role === AIMessageRole.User
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
: 'text-tint-strong'
)}
style={{
animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
}}
key={index}
>
{message.content ? message.content : null}
{isLastMessage ? (
<>
{!chat.loading &&
!chat.error &&
chat.query &&
chat.responseId &&
chat.pendingTools.length === 0 ? (
<AIResponseFeedback
responseId={chat.responseId}
query={chat.query}
className="-ml-1 -mt-4"
/>
) : null}
<AIChatToolConfirmations chat={chat} />
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
/>
</>
) : null}
</div>
{isLastMessage && chat.loading ? (
<div className="flex w-full animate-fade-in-slow flex-col gap-2">
{!message.content ? <HoldMessage /> : null}
<LoadingSkeleton />
</div>
) : null}
{isLastMessage ? (
<>
{!chat.loading &&
!chat.error &&
chat.query &&
chat.responseId &&
chat.pendingTools.length === 0 ? (
<AIResponseFeedback
responseId={chat.responseId}
query={chat.query}
className="-ml-1 -mt-4"
/>
) : null}
<AIChatToolConfirmations chat={chat} />
</>
) : null}
</div>
</>
);
})}
</>
@@ -1,143 +1,63 @@
import type { GitBookSiteContext } from '@/lib/context';
import { Icon } from '@gitbook/icons';
import React from 'react';
import { AdminToolbarClient } from './AdminToolbarClient';
import type { AdminToolbarContext } from './types';
import { tcls } from '@/lib/tailwind';
import { DateRelative } from '../primitives';
import { IframeWrapper } from './IframeWrapper';
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
import { Toolbar, ToolbarBody, ToolbarButton, ToolbarButtonGroups } from './Toolbar';
interface AdminToolbarProps {
export interface AdminToolbarProps {
context: GitBookSiteContext;
}
function ToolbarLayout(props: { children: React.ReactNode }) {
return (
<div
className={tcls(
'fixed',
'bottom-5',
'left-1/2',
'z-50',
'transform',
'-translate-x-1/2',
'rounded-full',
'bg-tint-12/9',
'dark:bg-tint-1/9',
'shadow-lg',
'min-h-10',
'min-w-40',
'p-2',
'max-w-md',
'border-tint-12/1',
'backdrop-blur-md'
)}
>
<React.Suspense fallback={null}>{props.children}</React.Suspense>
</div>
);
}
/**
* Toolbar with information for the content admin when previewing a revision or change-request.
* Server component that determines what type of toolbar to show and passes data to client component
*/
export async function AdminToolbar(props: AdminToolbarProps) {
const { context } = props;
if (context.changeRequest) {
return (
<IframeWrapper>
<ChangeRequestToolbar context={context} />
</IframeWrapper>
);
}
// Create a minimal context to avoid serializing and passing too many data to the client
const minimalContext: AdminToolbarContext = {
organizationId: context.organizationId,
revisionId: context.revisionId,
space: {
id: context.space.id,
revision: context.space.revision,
urls: {
app: context.space.urls.app,
},
},
changeRequest: context.changeRequest
? {
id: context.changeRequest.id,
number: context.changeRequest.number,
subject: context.changeRequest.subject,
revision: context.changeRequest.revision,
updatedAt: context.changeRequest.updatedAt,
createdBy: {
displayName: context.changeRequest.createdBy.displayName,
},
urls: {
app: context.changeRequest.urls.app,
},
}
: null,
revision: {
createdAt: context.revision.createdAt,
urls: {
app: context.revision.urls.app,
},
git: context.revision.git
? {
url: context.revision.git.url,
}
: null,
},
site: {
id: context.site.id,
title: context.site.title,
urls: {
app: context.site.urls.app,
published: context.site.urls.published,
},
},
};
if (context.revisionId !== context.space.revision) {
return (
<IframeWrapper>
<RevisionToolbar context={context} />
</IframeWrapper>
);
}
return null;
}
async function ChangeRequestToolbar(props: { context: GitBookSiteContext }) {
const { context } = props;
const { space, changeRequest } = context;
if (!changeRequest) {
return null;
}
return (
<ToolbarLayout>
<Toolbar>
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
<Icon icon="code-branch" className="size-4" />
</ToolbarButton>
<ToolbarBody>
<p>
#{changeRequest.number}: {changeRequest.subject ?? 'No subject'}
</p>
<p className="text-tint-2 text-xs dark:text-tint-11">
Change request updated <DateRelative value={changeRequest.updatedAt} />
</p>
</ToolbarBody>
<ToolbarButtonGroups>
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
<Icon icon="arrow-up-right-from-square" className="size-4" />
</ToolbarButton>
<RefreshChangeRequestButton
spaceId={space.id}
changeRequestId={changeRequest.id}
revisionId={changeRequest.revision}
updatedAt={new Date(changeRequest.updatedAt).getTime()}
/>
</ToolbarButtonGroups>
</Toolbar>
</ToolbarLayout>
);
}
async function RevisionToolbar(props: { context: GitBookSiteContext }) {
const { context } = props;
const { revision } = context;
return (
<ToolbarLayout>
<Toolbar>
<ToolbarButton title="Open in application" href={revision.urls.app}>
<Icon icon="code-commit" className="size-4" />
</ToolbarButton>
<ToolbarBody>
<p>
Revision created <DateRelative value={revision.createdAt} />
</p>
{revision.git ? (
<p className="text-tint-2 text-xs dark:text-tint-11">
{revision.git.message}
</p>
) : null}
</ToolbarBody>
<ToolbarButtonGroups>
<ToolbarButton title="Open in application" href={revision.urls.app}>
<Icon icon="arrow-up-right-from-square" className="size-4" />
</ToolbarButton>
{revision.git?.url ? (
<ToolbarButton title="Open git commit" href={revision.git.url}>
<Icon
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
className="size-4"
/>
</ToolbarButton>
) : null}
</ToolbarButtonGroups>
</Toolbar>
</ToolbarLayout>
);
return <AdminToolbarClient context={minimalContext} />;
}
@@ -0,0 +1,306 @@
'use client';
import { Icon } from '@gitbook/icons';
import { MotionConfig } from 'motion/react';
import { useCheckForContentUpdate } from '../AutoRefreshContent';
import { useVisitorSession } from '../Insights';
import { useCurrentPagePath } from '../hooks';
import { DateRelative } from '../primitives';
import { IframeWrapper } from './IframeWrapper';
import { RefreshContentButton } from './RefreshContentButton';
import {
Toolbar,
ToolbarBody,
ToolbarButton,
ToolbarButtonGroup,
type ToolbarButtonProps,
ToolbarSeparator,
ToolbarSubtitle,
ToolbarTitle,
} from './Toolbar';
import type { AdminToolbarClientProps } from './types';
export function AdminToolbarClient(props: AdminToolbarClientProps) {
const { context } = props;
const visitorSession = useVisitorSession();
// If there is a change request, show the change request toolbar
if (context.changeRequest) {
return (
<IframeWrapper>
<MotionConfig reducedMotion="user">
<ChangeRequestToolbar context={context} />
</MotionConfig>
</IframeWrapper>
);
}
// If the revision is not the current revision, the user is looking at a previous version of the site, so show the revision toolbar
if (context.revisionId !== context.space.revision) {
return (
<IframeWrapper>
<MotionConfig reducedMotion="user">
<RevisionToolbar context={context} />
</MotionConfig>
</IframeWrapper>
);
}
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
if (visitorSession?.organizationId === context.organizationId) {
return (
<IframeWrapper>
<MotionConfig reducedMotion="user">
<AuthenticatedUserToolbar context={context} />
</MotionConfig>
</IframeWrapper>
);
}
}
function ChangeRequestToolbar(props: AdminToolbarClientProps) {
const { context } = props;
const { changeRequest, site } = context;
if (!changeRequest) {
throw new Error('Change request is not set');
}
const author = changeRequest.createdBy.displayName;
const { refreshForUpdates, updated } = useCheckForContentUpdate({
revisionId: changeRequest.revision,
});
return (
<Toolbar>
<ToolbarBody>
<ToolbarTitle
prefix="Change request"
suffix={`#${changeRequest.number} ${changeRequest.subject || 'Untitled'}`}
/>
<ToolbarSubtitle
subtitle={
<>
<DateRelative value={changeRequest.updatedAt} /> by {author}
</>
}
/>
</ToolbarBody>
<ToolbarSeparator />
<ToolbarButtonGroup>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* Comment in app */}
<ToolbarButton
title="Comment in a GitBook"
href={getToolbarHref({
href: `${changeRequest.urls.app}~/comments`,
siteId: site.id,
buttonId: 'comment',
})}
icon="comment"
/>
{/* Open production site */}
{site.urls.published ? (
<ToolbarButton
title="Open production site"
href={getToolbarHref({
href: site.urls.published,
siteId: site.id,
buttonId: 'production-site',
})}
icon="globe"
/>
) : null}
{/* Open CR in GitBook */}
<ToolbarButton
title="View change request in GitBook"
href={getToolbarHref({
href: changeRequest.urls.app,
siteId: site.id,
buttonId: 'change-request',
})}
icon="code-pull-request"
/>
{/* Edit in GitBook */}
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
</ToolbarButtonGroup>
</Toolbar>
);
}
function RevisionToolbar(props: AdminToolbarClientProps) {
const { context } = props;
const { revision, site } = context;
if (!revision) {
throw new Error('Revision is not set');
}
const gitURL = revision.git?.url;
const isGitHub = gitURL?.includes('github.com');
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
return (
<Toolbar>
<ToolbarBody>
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Created <DateRelative value={revision.createdAt} />
</>
}
/>
</ToolbarBody>
<ToolbarSeparator />
<ToolbarButtonGroup>
{/* Open commit in Git client */}
<ToolbarButton
title={
gitURL ? (
`Open commit in ${gitProvider}`
) : (
<div className="flex items-center gap-2">
Setup GitSync to edit using Git{' '}
<div className="flex items-center gap-1 text-neutral-8 text-xs hover:text-neutral-6 hover:underline dark:text-neutral-3">
<a
href="https://gitbook.com/docs/getting-started/git-sync"
target="_blank"
rel="noopener noreferrer"
className=""
onClick={(e) => e.stopPropagation()}
>
Learn more
</a>
<Icon icon="arrow-up-right" className="size-3" />
</div>
</div>
)
}
href={gitURL}
disabled={!gitURL}
icon={gitURL ? (isGitHub ? 'github' : 'gitlab') : 'github'}
/>
{site.urls.published ? (
<ToolbarButton
title="Open production site"
href={getToolbarHref({
href: site.urls.published,
siteId: site.id,
buttonId: 'production-site',
})}
icon="globe"
/>
) : null}
<ToolbarButton
title="View this revision in GitBook"
href={getToolbarHref({
href: revision.urls.app,
siteId: site.id,
buttonId: 'revision',
})}
icon="code-commit"
/>
</ToolbarButtonGroup>
</Toolbar>
);
}
function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
const { context } = props;
const { revision, space, site } = context;
const { refreshForUpdates, updated } = useCheckForContentUpdate({
revisionId: space.revision,
});
return (
<Toolbar>
<ToolbarBody>
<ToolbarTitle prefix="Site" suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Updated <DateRelative value={revision.createdAt} />
</>
}
/>
</ToolbarBody>
<ToolbarSeparator />
<ToolbarButtonGroup>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
<ToolbarButton
title="Open site in GitBook"
href={getToolbarHref({
href: site.urls.app,
siteId: site.id,
buttonId: 'site',
})}
icon="gear"
/>
<ToolbarButton
title="Customize in GitBook"
href={getToolbarHref({
href: `${site.urls.app}/customization/general`,
siteId: site.id,
buttonId: 'customize',
})}
icon="palette"
/>
<ToolbarButton
title="Open insights in GitBook"
href={getToolbarHref({
href: `${site.urls.app}/insights`,
siteId: site.id,
buttonId: 'insights',
})}
icon="chart-simple"
/>
<EditPageButton href={space.urls.app} siteId={site.id} />
</ToolbarButtonGroup>
</Toolbar>
);
}
function EditPageButton(props: {
href: string;
siteId: string;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { href, motionValues, siteId } = props;
const pagePath = useCurrentPagePath();
return (
<ToolbarButton
title="Edit in GitBook"
href={getToolbarHref({
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
siteId,
buttonId: 'edit',
})}
icon="pencil"
motionValues={motionValues}
/>
);
}
/**
* Append utm parameters to a URL to track usage of the toolbar.
*/
function getToolbarHref({
href,
siteId,
buttonId,
}: { href: string; siteId: string; buttonId: string }) {
const url = new URL(href);
url.searchParams.set('utm_source', 'content');
url.searchParams.set('utm_medium', 'toolbar');
url.searchParams.set('utm_campaign', siteId);
url.searchParams.set('utm_content', buttonId);
return url.toString();
}
@@ -0,0 +1,180 @@
.svgLogo {
--logo-fill: var(--color-neutral-100);
--seg-A-color: #2782c4;
--seg-B-color: #43b7f2;
--seg-C-color: #8be2ff;
--T: 2s;
shape-rendering: geometricPrecision;
vector-effect: non-scaling-stroke;
}
:global(html.dark) .svgLogo {
--logo-fill: var(--color-neutral-800);
--seg-A-color: #19537d;
--seg-B-color: #2782c4;
--seg-C-color: #43b7f2;
--trace-color: #46474c;
}
/* Base segment animation */
.seg {
opacity: 0;
animation: segFade var(--T) ease both 0.06s, segAMove var(--T) linear,
segALen var(--T) linear;
animation-delay: 0.06s;
animation-fill-mode: forwards;
}
.segA {
animation-name: segFade, segAMove, segALen;
}
.segB {
animation-name: segFade, segBMove, segBLen;
}
.segC {
animation-name: segFade, segCMove, segCLen;
}
.trace {
animation: traceFill var(--T) ease both 0.06s;
animation-fill-mode: forwards;
}
@keyframes traceFill {
0% {
fill: transparent;
stroke:var(--trace-color);
}
95% {
fill: transparent;
stroke:var(--trace-color);
}
100% {
fill: var(--logo-fill);
stroke:none;
}
}
/* Segment A animation */
@keyframes segAMove {
0% {
stroke-dashoffset: 0;
stroke: var(--seg-A-color);
}
50% {
stroke-dashoffset: -0.5;
stroke: var(--seg-A-color);
}
95% {
stroke-dashoffset: -1;
}
100% {
stroke-dashoffset: 0;
stroke: none;
}
}
@keyframes segALen {
0% {
stroke-dasharray: 0.18 0.82;
}
95% {
stroke-dasharray: 0.22 0.78;
}
100% {
stroke-dasharray: 1;
}
}
/* Segment B animation */
@keyframes segBMove {
0% {
stroke-dashoffset: -0.18;
stroke: var(--seg-B-color);
}
50% {
stroke-dashoffset: -0.72;
stroke: var(--seg-B-color);
}
95% {
stroke-dashoffset: -1.18;
stroke: var(--seg-B-color);
}
100% {
stroke-dashoffset: 0;
stroke: none;
}
}
@keyframes segBLen {
0%,
50%,
95% {
stroke-dasharray: 0.2 0.8;
}
100% {
stroke-dasharray: 1;
}
}
/* Segment C animation */
@keyframes segCMove {
0% {
stroke-dashoffset: -0.38;
stroke: var(--seg-C-color);
}
50% {
stroke-dashoffset: -0.92;
stroke: var(--seg-C-color);
}
95% {
stroke-dashoffset: -1.38;
stroke: var(--seg-C-color);
}
100% {
stroke-dashoffset: 0;
stroke: none;
}
}
@keyframes segCLen {
0% {
stroke-dasharray: 0.22 0.78;
}
95% {
stroke-dasharray: 0.18 0.82;
}
100% {
stroke-dasharray: 1;
}
}
/* Segment fade animation - stays visible at end */
@keyframes segFade {
0% {
opacity: 0;
}
10% {
opacity: 1;
}
100% {
opacity: 1;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.seg {
animation: none;
opacity: 0;
}
.trace {
animation: none;
fill: var(--logo-fill);
stroke:none;
}
}
@@ -0,0 +1,62 @@
import { tcls } from '@/lib/tailwind';
import type React from 'react';
import styles from './AnimatedLogo.module.css';
export const AnimatedLogo: React.FC = () => {
return (
<svg
className={styles.svgLogo}
width="28"
height="28"
viewBox="0 0 28 28"
aria-hidden="true"
>
{/* Background trace */}
<path
d={dPath}
transform="translate(14,14) scale(0.4)"
strokeWidth={3}
pathLength={1}
strokeLinecap="round"
className={styles.trace}
/>
{/* Three individual paths to create the 'chasing' segmented stroke effect */}
<path
className={tcls(styles.seg, styles.segA)}
d={dPath}
transform="translate(14,14) scale(0.4)"
fill="none"
strokeWidth={3}
pathLength={1}
strokeLinecap="round"
strokeDasharray="0.18 0.82"
strokeDashoffset={0}
/>
<path
className={tcls(styles.seg, styles.segB)}
d={dPath}
transform="translate(14,14) scale(0.4)"
fill="none"
strokeWidth={3}
pathLength={1}
strokeLinecap="round"
strokeDasharray="0.20 0.80"
strokeDashoffset={-0.18}
/>
<path
className={tcls(styles.seg, styles.segC)}
d={dPath}
transform="translate(14,14) scale(0.4)"
fill="none"
strokeWidth={3}
pathLength={1}
strokeLinecap="round"
strokeDasharray="0.22 0.78"
strokeDashoffset={-0.38}
/>
</svg>
);
};
const dPath =
'M-5.07306 1.64898C-1.92626 3.46518 -0.352865 4.37328 1.37504 4.37477C3.10303 4.37628 4.67794 3.47098 7.82794 1.66027C7.82794 1.66027 27.9071 -9.88183 27.9071 -9.88183C28.8136 -10.4029 29.3724 -11.3687 29.3724 -12.4143C29.3724 -13.4598 28.8136 -14.4257 27.9071 -14.9467C27.9071 -14.9467 7.82063 -26.4931 7.82063 -26.4931C4.67414 -28.3018 3.10083 -29.2062 1.37453 -29.2055C-0.351665 -29.2048 -1.92427 -28.2992 -5.06947 -26.488C-5.06947 -26.488 -22.3372 -16.5441 -22.3372 -16.5441C-22.4651 -16.4704 -22.5291 -16.4336 -22.5888 -16.3986C-28.4872 -12.9457 -32.1315 -6.64173 -32.1802 0.192975C-32.1807 0.262075 -32.1807 0.335975 -32.1807 0.483575C-32.1807 0.631075 -32.1807 0.704875 -32.1802 0.773875C-32.1316 7.60087 -28.4955 13.899 -22.6075 17.3547C-22.548 17.3897 -22.4841 17.4266 -22.3564 17.5003C-22.3564 17.5003 -11.5399 23.7454 -11.5399 23.7454C-5.23716 27.3844 -2.08587 29.2039 1.37484 29.2051C4.83554 29.2062 7.98813 27.3889 14.2932 23.7541C14.2932 23.7541 25.7115 17.1718 25.7115 17.1718C28.8686 15.3518 30.4471 14.4418 31.3139 12.9416C32.1807 11.4414 32.1807 9.61938 32.1807 5.97517C32.1807 5.97517 32.1807 -1.06463 32.1807 -1.06463C32.1807 -2.07553 31.6332 -3.00723 30.75 -3.49913C29.8953 -3.97513 28.8536 -3.96802 28.0054 -3.48053C28.0054 -3.48053 4.59224 9.97808 4.59224 9.97808C3.02144 10.8811 2.23593 11.3326 1.37404 11.3329C0.512135 11.3331 -0.273565 10.8821 -1.84506 9.98007C-1.84506 9.98007 -17.6916 0.883775 -17.6916 0.883775C-18.4854 0.428075 -18.8823 0.200275 -19.2011 0.159175C-19.9279 0.065375 -20.6267 0.472475 -20.9036 1.15108C-21.025 1.44858 -21.0225 1.90628 -21.0176 2.82148C-21.014 3.49528 -21.0122 3.83218 -20.9492 4.14208C-20.8082 4.83607 -20.4431 5.46448 -19.91 5.93068C-19.672 6.13888 -19.3802 6.30727 -18.7966 6.64407C-18.7966 6.64407 -1.85397 16.4227 -1.85397 16.4227C-0.278465 17.332 0.509335 17.7867 1.37434 17.7869C2.23934 17.7872 3.02734 17.3329 4.60333 16.4245C4.60333 16.4245 25.3699 4.45377 25.3699 4.45377C25.9083 4.14348 26.1774 3.98828 26.3792 4.10488C26.5811 4.22148 26.5811 4.53218 26.5811 5.15357C26.5811 5.15357 26.5811 8.34667 26.5811 8.34667C26.5811 9.25768 26.5811 9.71317 26.3643 10.0883C26.1476 10.4634 25.753 10.6908 24.9637 11.1458C24.9637 11.1458 7.83524 21.0193 7.83524 21.0193C4.68194 22.837 3.10533 23.7458 1.37463 23.7451C-0.356065 23.7443 -1.93186 22.834 -5.08347 21.0134C-5.08347 21.0134 -21.1086 11.7563 -21.1086 11.7563C-21.1595 11.7269 -21.1849 11.7122 -21.2087 11.6984C-24.5687 9.73487 -26.642 6.14287 -26.6614 2.25128C-26.6616 2.22378 -26.6616 2.19438 -26.6616 2.13567C-26.6616 2.13567 -26.6616 -0.795425 -26.6616 -0.795425C-26.6616 -2.94372 -25.5174 -4.92953 -23.6587 -6.00682C-22.0163 -6.95883 -19.9905 -6.96072 -18.3463 -6.01183C-18.3463 -6.01183 -5.07306 1.64898 -5.07306 1.64898Z';
@@ -1,70 +0,0 @@
'use client';
import { Icon } from '@gitbook/icons';
import React from 'react';
import { useCheckForContentUpdate } from '@/components/AutoRefreshContent';
import { tcls } from '@/lib/tailwind';
import { ToolbarButton } from './Toolbar';
// We don't show the button if the content has been updated 30s ago or less.
const minInterval = 1000 * 30; // 5 minutes
/**
* Button to refresh the page if the content has been updated.
*/
export function RefreshChangeRequestButton(props: {
spaceId: string;
changeRequestId: string;
revisionId: string;
updatedAt: number;
}) {
const { updatedAt } = props;
const [visible, setVisible] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const checkForUpdates = useCheckForContentUpdate(props);
const refresh = React.useCallback(async () => {
setLoading(true);
try {
await checkForUpdates();
} finally {
setLoading(false);
setVisible(false);
}
}, [checkForUpdates]);
// Show the button if the content has been updated more than 30s ago.
React.useEffect(() => {
if (updatedAt < Date.now() - minInterval) {
setVisible(true);
}
}, [updatedAt]);
// 30sec after being hidden, we show the button again
React.useEffect(() => {
if (!visible) {
const timeout = setTimeout(() => {
setVisible(true);
}, minInterval);
return () => clearTimeout(timeout);
}
}, [visible]);
if (!visible) {
return null;
}
return (
<ToolbarButton
title="Refresh"
onClick={(event) => {
event.preventDefault();
refresh();
}}
>
<Icon icon="rotate" className={tcls('size-4', loading ? 'animate-spin' : null)} />
</ToolbarButton>
);
}
@@ -0,0 +1,42 @@
'use client';
import React from 'react';
import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
/**
* Button to refresh the page if the content has been updated.
*/
export function RefreshContentButton(props: {
refreshForUpdates: () => Promise<void>;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { refreshForUpdates, motionValues } = props;
const [loading, setLoading] = React.useState(false);
const refresh = React.useCallback(async () => {
setLoading(true);
try {
await refreshForUpdates();
} finally {
setLoading(false);
}
}, [refreshForUpdates]);
return (
<ToolbarButton
title="Refresh for latest changes"
onClick={(event) => {
if (loading) {
return;
}
event.preventDefault();
refresh();
}}
disabled={loading}
motionValues={motionValues}
icon="rotate"
iconClassName={loading ? 'animate-spin' : undefined}
/>
);
}
@@ -1,65 +1,288 @@
'use client';
import type * as React from 'react';
import { AnimatePresence, type MotionValue, motion, useReducedMotion } from 'motion/react';
import React from 'react';
import { AnimatedLogo } from './AnimatedLogo';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import { Tooltip } from '../primitives';
import { getCopyVariants, minifyButtonAnimation, toolbarEasings } from './transitions';
import { useMagnificationEffect } from './useMagnificationEffect';
const DURATION_LOGO_APPEARANCE = 2000;
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
export function Toolbar(props: { children: React.ReactNode }) {
const { children } = props;
const [minified, setMinified] = React.useState(true);
const [showToolbarControls, setShowToolbarControls] = React.useState(false);
const [isReady, setIsReady] = React.useState(false);
// Wait for page to be ready, then show the toolbar
React.useEffect(() => {
const handleLoad = () => {
setIsReady(true);
};
if (document.readyState === 'complete') {
handleLoad();
} else {
window.addEventListener('load', handleLoad);
return () => window.removeEventListener('load', handleLoad);
}
}, []);
// After toolbar appears, wait then show the full content
React.useEffect(() => {
if (isReady) {
const expandAfterTimeout = setTimeout(() => {
setMinified(false);
}, DURATION_LOGO_APPEARANCE + DELAY_BETWEEN_LOGO_AND_CONTENT);
return () => clearTimeout(expandAfterTimeout);
}
}, [isReady]);
// Don't render anything until page is ready
if (!isReady) {
return null;
}
return (
<div
className={tcls(
'flex',
'flex-row',
'items-center',
'gap-4',
'text-sm',
'px-4',
'py-1',
'rounded-full',
'truncate',
'text-tint-1',
'dark:text-tint-12'
)}
<motion.div
onMouseEnter={() => setShowToolbarControls(true)}
onMouseLeave={() => setShowToolbarControls(false)}
className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4"
>
{children}
</div>
<AnimatePresence mode="wait">
<motion.div
onClick={() => {
if (minified) {
setMinified((prev) => !prev);
}
}}
layout
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
'flex',
'items-center',
'justify-center',
'min-h-11',
'min-w-12',
'h-12',
'py-2',
'border-tint-1/3',
'backdrop-blur-sm',
'origin-center',
'bg-[linear-gradient(110deg,rgba(20,23,28,0.90)_0%,rgba(20,23,28,0.80)_100%)]',
'dark:bg-[linear-gradient(110deg,rgba(256,256,256,0.90)_0%,rgba(256,256,256,0.80)_100%)]'
)}
initial={{
scale: 1,
opacity: 1,
}}
animate={{
scale: 1,
opacity: 1,
boxShadow: minified
? '0 4px 40px 8px rgba(0, 0, 0, .2), 0 0 0 .5px rgba(0, 0, 0, .4), inset 0 .5px 0 0 hsla(0, 0%, 100%, .15)'
: '0 4px 40px 8px rgba(0, 0, 0, .4), 0 0 0 .5px rgba(0, 0, 0, .8), inset 0 .5px 0 0 hsla(0, 0%, 100%, .3)',
}}
style={{
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
}}
>
{/* Logo with stroke segments animation in blue-tints */}
<motion.div layout>
<AnimatedLogo />
</motion.div>
{!minified ? props.children : null}
{!minified && showToolbarControls && <MinifyButton setMinified={setMinified} />}
</motion.div>
</AnimatePresence>
</motion.div>
);
}
export function ToolbarBody(props: { children: React.ReactNode }) {
return <div className="flex flex-col gap-1">{props.children}</div>;
return <div className="flex flex-col items-start px-3">{props.children}</div>;
}
export function ToolbarButtonGroups(props: { children: React.ReactNode }) {
return <div className="flex flex-row gap-2">{props.children}</div>;
}
export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
const { children } = props;
const containerRef = React.useRef<HTMLDivElement>(null);
const buttonChildren = React.Children.toArray(children).filter((child) => !!child);
const { buttonMotionValues } = useMagnificationEffect({
childrenCount: buttonChildren.length,
containerRef,
});
export function ToolbarButton(props: React.HTMLProps<HTMLAnchorElement>) {
const { children, ...rest } = props;
return (
<a
{...rest}
className={tcls(
'flex',
'flex-col',
'items-center',
'justify-center',
'size-11',
'gap-1',
'text-sm',
'rounded-full',
'truncate',
'text-tint-1', // Equal to text-tint-strong in dark mode
'dark:text-tint-12', // Equal to text-tint-strong in dark mode
'hover:bg-tint-12',
'dark:hover:bg-tint-1',
'hover:shadow-lg',
'cursor-pointer'
)}
<motion.div
ref={containerRef}
variants={toolbarEasings.parent}
initial="hidden"
animate="show"
className="flex items-center gap-1 overflow-visible pr-2 pl-4"
>
{children}
</a>
{buttonChildren.map((child, index) => {
const motionValues = buttonMotionValues[index];
const childEl = child as React.ReactElement;
return React.cloneElement(childEl, {
key: index,
motionValues,
});
})}
</motion.div>
);
}
export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorElement>, 'title'> {
motionValues?: {
scale: MotionValue<number>;
x: MotionValue<number>;
};
icon: IconName;
iconClassName?: string;
title?: React.ReactNode;
}
export function ToolbarButton(props: ToolbarButtonProps) {
const { title, disabled, motionValues, className, style, href, onClick, icon, iconClassName } =
props;
const reduceMotion = useReducedMotion();
return (
<motion.div variants={toolbarEasings.staggeringChild}>
<Tooltip label={title}>
<motion.a
href={href}
onClick={onClick}
target="_blank"
rel="noopener noreferrer"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
...style,
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
className,
'flex',
'relative',
'items-center',
'justify-center',
'gap-1',
'text-sm',
'rounded-full',
'border-neutral-500',
'outline-neutral-800',
'outline-1',
'border',
'truncate',
'text-tint-1',
'dark:text-tint-12',
'cursor-pointer',
'transition-colors',
'size-8',
'bg-tint-1/3',
'hover:bg-tint-1/4',
'dark:bg-tint-3',
'dark:hover:bg-tint-1',
disabled ? 'cursor-not-allowed opacity-50' : '',
'shadow-1xs'
)}
>
<Icon icon={icon} className={tcls('size-4', iconClassName)} />
</motion.a>
</Tooltip>
</motion.div>
);
}
export function ToolbarSeparator() {
return <div className="h-5 w-px bg-tint-1/3" />;
}
export function ToolbarTitle(props: { prefix: string; suffix: string }) {
return (
<div className="flex items-center gap-1 text-xs ">
<ToolbarTitlePrefix title={props.prefix} />
<ToolbarTitleSuffix title={props.suffix} />
</div>
);
}
function ToolbarTitlePrefix(props: { title: string }) {
return (
<motion.span
{...getCopyVariants(0)}
className="font-light text-neutral-7 dark:text-neutral-3"
>
{props.title}
</motion.span>
);
}
function ToolbarTitleSuffix(props: { title: string }) {
return (
<motion.span
{...getCopyVariants(1)}
className="max-w-[24ch] truncate font-semibold text-neutral-3 dark:text-neutral-2"
>
{props.title}
</motion.span>
);
}
export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
return (
<motion.span
{...getCopyVariants(1)}
className="text-neutral-7 text-xxs dark:text-neutral-2"
>
{props.subtitle}
</motion.span>
);
}
function MinifyButton(props: { setMinified: (minified: boolean) => void }) {
return (
<Tooltip label="Minify">
<motion.div
{...minifyButtonAnimation}
transition={{
duration: 0.2,
}}
whileHover={{
scale: 1.05,
}}
onClick={(e) => {
e.stopPropagation();
props.setMinified(true);
}}
className={tcls(
'-top-2 -right-4 absolute flex size-4 cursor-pointer items-center justify-center rounded-full border',
'border-neutral-500 bg-neutral-700 hover:border-neutral-400 hover:bg-neutral-600',
'dark:border-neutral-400 dark:bg-neutral-200 dark:hover:border-neutral-200 dark:hover:bg-neutral-100'
)}
>
<Icon icon="minus" className="size-2 text-neutral-1 dark:text-neutral-9" />
</motion.div>
</Tooltip>
);
}
@@ -1,2 +1,5 @@
export * from './AdminToolbar';
export * from './AdminToolbarClient';
export * from './IframeWrapper';
export * from './Toolbar';
export * from './transitions';
@@ -0,0 +1,73 @@
import type { Transition } from 'motion/react';
// Slight bounce but minimal overshoot esp. at the minimized state.
const spring = {
type: 'spring' as const,
stiffness: 130,
damping: 19,
mass: 1,
};
const parent = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
delayChildren: 0.4,
staggerChildren: 0.1,
},
},
};
const staggeringChild = {
hidden: {
opacity: 0,
scale: 0.7,
},
show: {
opacity: 1,
scale: 1,
transition: {
duration: 0.3,
type: 'spring',
} as Transition,
},
};
export const minifyButtonAnimation = {
initial: {
scale: 0.5,
opacity: 0,
},
animate: {
scale: 1,
opacity: 1,
},
exit: {
scale: 0.5,
opacity: 0,
},
};
export const getCopyVariants = (position: number) => {
return {
initial: {
opacity: 0,
x: -10,
},
animate: {
opacity: 1,
x: 0,
},
transition: {
duration: 0.1,
delay: position / 10 + 0.3,
} as Transition,
};
};
export const toolbarEasings = {
spring,
parent,
staggeringChild,
};
@@ -0,0 +1,57 @@
/*
* Minimal types containing only the fields needed for AdminToolbar to restrict what gets serialized
*/
export type MinimalChangeRequest = {
id: string;
number: number;
subject: string | null;
revision: string;
updatedAt: string;
createdBy: {
displayName: string;
};
urls: {
app: string;
};
};
export type MinimalRevision = {
createdAt: string;
urls: {
app: string;
};
git?: {
url: string | undefined;
} | null;
};
export type MinimalSpace = {
id: string;
revision: string;
urls: {
app: string;
};
};
export type MinimalSite = {
id: string;
title: string;
urls: {
app: string;
published: string | undefined;
};
};
export type AdminToolbarContext = {
organizationId: string;
revisionId: string;
space: MinimalSpace;
changeRequest: MinimalChangeRequest | null;
revision: MinimalRevision;
site: MinimalSite;
};
export interface AdminToolbarClientProps {
context: AdminToolbarContext;
}
@@ -0,0 +1,226 @@
import { type MotionValue, motionValue } from 'framer-motion';
import React from 'react';
interface ButtonMotionValues {
scale: MotionValue<number>;
x: MotionValue<number>;
}
interface MagnificationConfig {
/** Size of each button in pixels - used for spacing calculations */
buttonSize?: number;
/** Maximum scale factor for buttons when mouse is directly over them */
maxScale?: number;
/** Distance in pixels from mouse where buttons start scaling */
influenceRadius?: number;
/** Multiplier for spacing between buttons - higher values create more space */
spacingMultiplier?: number;
/** Controls scaling curve - higher values make scaling more dramatic near mouse */
scaleExponent?: number;
/** Padding around container for mouse detection in pixels */
padding?: number;
}
const defaultConfig: Required<MagnificationConfig> = {
buttonSize: 32, // Standard button size for 32px (size-8) buttons
maxScale: 1.3, // 30% scale increase - noticeable but not overwhelming
influenceRadius: 80, // ~2.5 button widths of influence
spacingMultiplier: 1.3, // Creates proportional spacing to scale increase
scaleExponent: 2.5, // Exponential curve for dramatic close-range scaling
padding: 10, // Small buffer zone around container edges
};
// Helper functions for cleaner code
const createMotionValues = (count: number): ButtonMotionValues[] =>
Array.from({ length: count }, () => ({
scale: motionValue(1),
x: motionValue(0),
}));
const resetMotionValues = (motionValues: ButtonMotionValues[]) => {
motionValues.forEach(({ scale, x }) => {
scale.set(1);
x.set(0);
});
};
const captureButtonPositions = (buttons: HTMLElement[]) => {
// Reset transforms to get original positions
buttons.forEach((button) => (button.style.transform = ''));
return buttons.map((button) => {
const rect = button.getBoundingClientRect();
return { left: rect.left, width: rect.width };
});
};
const calculateScale = (
mouseX: number,
buttonCenterX: number,
containerRect: DOMRect,
config: Required<MagnificationConfig>
) => {
const distance = Math.abs(mouseX - buttonCenterX);
if (distance > config.influenceRadius) return 1;
// Edge influence to reduce scaling near container edges
const distanceFromEdge = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
const edgeInfluence = Math.min(distanceFromEdge / 20, 1);
// Exponential scaling curve
const progress = distance / config.influenceRadius;
const exponentialProgress = (1 - progress) ** config.scaleExponent;
const baseScale = 1 + (config.maxScale - 1) * exponentialProgress;
return 1 + (baseScale - 1) * edgeInfluence;
};
const calculateSpacing = (
buttonIndex: number,
buttonEffects: Array<{ scale: number; translateX: number }>,
positions: Array<{ left: number; width: number }>,
config: Required<MagnificationConfig>
) => {
const currentPos = positions[buttonIndex];
if (!currentPos) return 0;
const buttonCenterX = currentPos.left + currentPos.width / 2;
let leftPush = 0;
let rightPush = 0;
buttonEffects.forEach((otherEffect, otherIndex) => {
if (otherIndex === buttonIndex) return;
const otherPos = positions[otherIndex];
if (!otherPos) return;
const otherCenterX = otherPos.left + otherPos.width / 2;
const distanceBetween = Math.abs(buttonCenterX - otherCenterX);
// Only apply influence if close enough
if (distanceBetween < config.influenceRadius * 1.5) {
const influenceStrength = Math.max(
0,
1 - distanceBetween / (config.influenceRadius * 1.5)
);
const extraSpace = (otherEffect.scale - 1) * config.buttonSize;
const pushAmount = (extraSpace / 2) * config.spacingMultiplier * influenceStrength;
if (otherCenterX < buttonCenterX) {
rightPush += pushAmount;
} else {
leftPush += pushAmount;
}
}
});
return rightPush - leftPush;
};
export function useMagnificationEffect(props: {
childrenCount: number;
containerRef: React.RefObject<HTMLElement>;
config?: MagnificationConfig;
}) {
const { childrenCount, containerRef, config } = props;
const [buttonMotionValues, setButtonMotionValues] = React.useState<ButtonMotionValues[]>([]);
const originalPositionsRef = React.useRef<Array<{ left: number; width: number }>>([]);
const finalConfig = { ...defaultConfig, ...config };
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const buttons = Array.from(container.querySelectorAll('.toolbar-button')) as HTMLElement[];
if (buttons.length !== childrenCount) {
console.error(
`Button count (${buttons.length}) does not match children count (${childrenCount})`
);
return;
}
// Initialize motion values if button count changed
if (buttonMotionValues.length !== buttons.length) {
setButtonMotionValues(createMotionValues(buttons.length));
}
const handleMouseMove = (event: MouseEvent) => {
const buttons = Array.from(
container.querySelectorAll('.toolbar-button')
) as HTMLElement[];
// Capture positions if needed
if (originalPositionsRef.current.length !== buttons.length) {
originalPositionsRef.current = captureButtonPositions(buttons);
}
const containerRect = container.getBoundingClientRect();
const { clientX: mouseX, clientY: mouseY } = event;
// Check if mouse is in range
const isInRange =
mouseX >= containerRect.left - finalConfig.padding &&
mouseX <= containerRect.right + finalConfig.padding &&
mouseY >= containerRect.top - finalConfig.padding &&
mouseY <= containerRect.bottom + finalConfig.padding;
if (!isInRange) {
resetMotionValues(buttonMotionValues);
return;
}
// Calculate scales for all buttons
const buttonEffects = buttons.map((_, index) => {
const pos = originalPositionsRef.current[index];
if (!pos) return { scale: 1, translateX: 0 };
const buttonCenterX = pos.left + pos.width / 2;
const scale = calculateScale(mouseX, buttonCenterX, containerRect, finalConfig);
return { scale, translateX: 0 };
});
// Calculate spacing for all buttons
buttonEffects.forEach((effect, index) => {
effect.translateX = calculateSpacing(
index,
buttonEffects,
originalPositionsRef.current,
finalConfig
);
});
// Update motion values
buttonEffects.forEach((effect, index) => {
const motionValue = buttonMotionValues[index];
if (motionValue) {
motionValue.scale.set(effect.scale);
motionValue.x.set(effect.translateX);
}
});
};
const handleMouseLeave = () => resetMotionValues(buttonMotionValues);
container.addEventListener('mousemove', handleMouseMove);
container.addEventListener('mouseleave', handleMouseLeave);
return () => {
container.removeEventListener('mousemove', handleMouseMove);
container.removeEventListener('mouseleave', handleMouseLeave);
};
}, [
finalConfig.buttonSize,
finalConfig.maxScale,
finalConfig.influenceRadius,
finalConfig.spacingMultiplier,
finalConfig.scaleExponent,
finalConfig.padding,
containerRef,
buttonMotionValues,
childrenCount,
]);
return { buttonMotionValues };
}
@@ -1,28 +1,43 @@
'use server';
import { getDataOrNull } from '@/lib/data';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
/**
* Return true if a change-request has been updated.
* Return true if the content has been updated.
* It compares the revision ID displayed on the client side with the latest revision ID from the server/cache.
*/
export async function hasContentBeenUpdated(props: {
spaceId: string;
changeRequestId: string;
revisionId: string;
}) {
return traceErrorOnly('AutoRefreshContent.hasContentBeenUpdated', async () => {
const context = await getServerActionBaseContext();
const changeRequest = await getDataOrNull(
context.dataFetcher.getChangeRequest({
spaceId: props.spaceId,
changeRequestId: props.changeRequestId,
const siteURLData = await getSiteURLDataFromMiddleware();
if (siteURLData.changeRequest) {
const changeRequest = await getDataOrNull(
context.dataFetcher.getChangeRequest({
spaceId: siteURLData.space,
changeRequestId: siteURLData.changeRequest,
})
);
if (!changeRequest) {
return false;
}
return changeRequest.revision !== props.revisionId;
}
const space = await getDataOrNull(
context.dataFetcher.getSpace({
spaceId: siteURLData.space,
shareKey: siteURLData.shareKey,
})
);
if (!changeRequest) {
if (!space) {
return false;
}
return changeRequest.revision !== props.revisionId;
return space.revision !== props.revisionId;
});
}
@@ -5,20 +5,71 @@ import React from 'react';
import { hasContentBeenUpdated } from './server-actions';
/**
* Return a callback to check if a change request has been updated and to refresh the page if it has.
* Return a callback to check if a content has been updated and to refresh the page if it has.
*/
export function useCheckForContentUpdate(props: {
spaceId: string;
changeRequestId: string;
/**
* ID of the revision of the content currently being displayed.
*/
revisionId: string;
/**
* How long after page load to check for updates.
* @default 5min
*/
checkAfterLoad?: number;
}) {
const { spaceId, changeRequestId, revisionId } = props;
return React.useCallback(async () => {
const updated = await hasContentBeenUpdated({ spaceId, changeRequestId, revisionId });
const { revisionId, checkAfterLoad = 5 * 60 * 1000 } = props;
const [updated, setUpdated] = React.useState(false);
const checkForUpdates = React.useCallback(async () => {
if (updated) {
// No need to check for updates if we already know the content has been updated
return true;
}
const result = await hasContentBeenUpdated({ revisionId });
setUpdated(result);
return result;
}, [revisionId, updated]);
const refreshForUpdates = React.useCallback(async () => {
if (await checkForUpdates()) {
window.location.reload();
}
}, [spaceId, changeRequestId, revisionId]);
}, [checkForUpdates]);
// Check for updates after the page has loaded
React.useEffect(() => {
const timeout = setTimeout(() => {
if (document.visibilityState === 'visible') {
// We check only if the tab is visible; otherwise we'll check when the tab is focus again
checkForUpdates();
}
}, checkAfterLoad);
return () => {
clearTimeout(timeout);
};
}, [checkForUpdates, checkAfterLoad]);
// Check for updates when the tab is becoming visible
React.useEffect(() => {
if (updated) {
// No need to check for updates if we already know the content has been updated
return;
}
const callback = () => {
if (document.visibilityState === 'visible') {
checkForUpdates();
}
};
document.addEventListener('visibilitychange', callback);
return () => {
document.removeEventListener('visibilitychange', callback);
};
}, [checkForUpdates, updated]);
return { refreshForUpdates, updated };
}
@@ -120,9 +120,28 @@ export function Header(props: {
>
<SearchContainer
style={customization.styling.search}
isMultiVariants={siteSpaces.length > 1}
withVariants={withVariants === 'generic'}
withSiteVariants={
sections?.list.some(
(s) =>
s.object === 'site-section' &&
s.siteSpaces.filter(
(s) => s.space.language === siteSpace.space.language
).length > 1
) ?? false
}
withSections={!!sections}
section={
sections
? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear
encodeClientSiteSections(context, sections).current
: undefined
}
spaceTitle={siteSpace.title}
siteSpaceId={siteSpace.id}
siteSpaceIds={siteSpaces
.filter((s) => s.space.language === siteSpace.space.language)
.map((s) => s.id)}
viewport={!withTopHeader ? 'mobile' : undefined}
/>
</div>
@@ -8,7 +8,7 @@ import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
import { getAllBrowserCookiesMap } from '@/lib/browser';
import { type CurrentContentContext, useCurrentContent } from '../hooks';
import { getSession } from './sessions';
import { getVisitorId } from './visitorId';
import { type SessionResponse, useVisitorSession } from './visitorId';
export type InsightsEventName = api.SiteInsightsEvent['type'];
@@ -55,12 +55,6 @@ interface InsightsProviderProps {
/** If true, the events will be sent to the server. */
enabled: boolean;
/** If true, the visitor cookie tracking will be used */
visitorCookieTrackingEnabled: boolean;
/** The application URL. */
appURL: string;
/** The url of the endpoint to send events to */
eventUrl: string;
@@ -72,10 +66,10 @@ interface InsightsProviderProps {
* Wrap the content of the app with the InsightsProvider to track events.
*/
export function InsightsProvider(props: InsightsProviderProps) {
const { enabled, children, visitorCookieTrackingEnabled, eventUrl, appURL } = props;
const { enabled, children, eventUrl } = props;
const visitorSession = useVisitorSession();
const currentContent = useCurrentContent();
const visitorIdRef = React.useRef<string | null>(null);
const eventsRef = React.useRef<{
[pathname: string]:
| {
@@ -92,9 +86,8 @@ export function InsightsProvider(props: InsightsProviderProps) {
*/
const flushEventsSync = useEventCallback(() => {
const session = getSession();
const visitorId = visitorIdRef.current;
if (!visitorId) {
throw new Error('Visitor ID should be set before flushing events');
if (!visitorSession) {
return;
}
const allEvents: api.SiteInsightsEvent[] = [];
@@ -114,7 +107,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
events: eventsForPathname.events,
context: currentContent,
pageContext: eventsForPathname.pageContext,
visitorId,
visitorSession,
sessionId: session.id,
})
);
@@ -126,25 +119,25 @@ export function InsightsProvider(props: InsightsProviderProps) {
};
}
if (allEvents.length > 0) {
if (enabled) {
sendEvents({
eventUrl,
events: allEvents,
});
} else {
}
if (allEvents.length > 0 && enabled) {
sendEvents({
eventUrl,
events: allEvents,
});
}
});
const flushBatchedEvents = useDebounceCallback(async () => {
const visitorId =
visitorIdRef.current ?? (await getVisitorId(appURL, visitorCookieTrackingEnabled));
visitorIdRef.current = visitorId;
const flushBatchedEvents = useDebounceCallback(() => {
flushEventsSync();
}, 1500);
// Flush pending events once the visitor session has been fetched
React.useEffect(() => {
if (visitorSession) {
flushEventsSync();
}
}, [visitorSession, flushEventsSync]);
const trackEvent: TrackEventCallback = useEventCallback(
(
event: TrackEventInput<InsightsEventName>,
@@ -169,7 +162,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
if (eventsRef.current[pathname].pageContext !== undefined) {
// If the pageId is set, we know that the page_view event has been tracked
// and we can flush the events
if (options?.immediate && visitorIdRef.current) {
if (options?.immediate) {
flushBatchedEvents.cancel();
flushEventsSync();
} else {
@@ -180,18 +173,14 @@ export function InsightsProvider(props: InsightsProviderProps) {
);
/**
* Get the visitor ID and store it in a ref.
* When the page is unloaded, flush all events.
*/
React.useEffect(() => {
getVisitorId(appURL, visitorCookieTrackingEnabled).then((visitorId) => {
visitorIdRef.current = visitorId;
// When the page is unloaded, flush all events, but only if the visitor ID is set
window.addEventListener('beforeunload', flushEventsSync);
});
window.addEventListener('beforeunload', flushEventsSync);
return () => {
window.removeEventListener('beforeunload', flushEventsSync);
};
}, [flushEventsSync, visitorCookieTrackingEnabled, appURL]);
}, [flushEventsSync]);
return (
<InsightsContext.Provider value={trackEvent}>
@@ -245,12 +234,12 @@ function transformEvents(input: {
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext: InsightsEventPageContext;
visitorId: string;
visitorSession: SessionResponse;
sessionId: string;
}): api.SiteInsightsEvent[] {
const session: api.SiteInsightsEventSession = {
sessionId: input.sessionId,
visitorId: input.visitorId,
visitorId: input.visitorSession.deviceId,
userAgent: window.navigator.userAgent,
language: window.navigator.language,
cookies: getAllBrowserCookiesMap(),
@@ -1,73 +0,0 @@
'use client';
import { getBrowserCookie } from '@/lib/browser';
import { isCookiesTrackingDisabled } from './cookies';
import { generateRandomId } from './utils';
const VISITORID_COOKIE = '__session';
let visitorId: string | null = null;
let pendingVisitorId: Promise<string> | null = null;
/**
* Return the current visitor identifier.
*/
export async function getVisitorId(
appURL: string,
visitorCookieTrackingEnabled: boolean
): Promise<string> {
if (!visitorId) {
if (!pendingVisitorId) {
pendingVisitorId = fetchVisitorID(appURL, visitorCookieTrackingEnabled).finally(() => {
pendingVisitorId = null;
});
}
visitorId = await pendingVisitorId;
}
return visitorId;
}
/**
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
*/
async function fetchVisitorID(
appURL: string,
visitorCookieTrackingEnabled: boolean
): Promise<string> {
const withoutCookies = isCookiesTrackingDisabled();
if (withoutCookies || !visitorCookieTrackingEnabled) {
return generateRandomId();
}
const existingTrackingCookie = getBrowserCookie(VISITORID_COOKIE);
if (existingTrackingCookie) {
// If the cookie already exists, we'll just use that. Avoids a server request.
return existingTrackingCookie;
}
// No tracking deviceId set, we'll need to consolidate with the server.
const proposed = generateRandomId();
const url = new URL(appURL);
url.pathname = '/__session/2/';
url.searchParams.set('proposed', proposed);
try {
const resp = await fetch(url, {
method: 'GET', // Use GET to play nicely with SameSite cookies.
credentials: 'include', // Make sure to send/receive cookies.
cache: 'no-cache',
mode: 'cors', // Need to use cors as we are on a different domain.
});
const { deviceId } = (await resp.json()) as { deviceId: string };
return deviceId;
} catch (error) {
console.error('Failed to fetch visitor session ID', error);
return proposed;
}
}
@@ -0,0 +1,130 @@
'use client';
import { createStore, useStore } from 'zustand';
import { getBrowserCookie } from '@/lib/browser';
import React from 'react';
import { isCookiesTrackingDisabled } from './cookies';
import { generateRandomId } from './utils';
const VISITORID_COOKIE = '__session';
type SessionVisitorResponse = {
deviceId: string;
userId?: undefined;
organizationId?: undefined;
};
type SessionUserResponse = SessionVisitorResponse & {
userId: string;
organizationId: string;
};
export type SessionResponse = SessionVisitorResponse | SessionUserResponse;
const visitorSessionStore = createStore<{
session: SessionResponse | null;
pendingSession: Promise<SessionResponse> | null;
}>(() => ({
session: null,
pendingSession: null,
}));
/**
* Fetch, synchronize the visitor sesion with GitBook.
*/
export function VisitorSessionProvider(
props: React.PropsWithChildren<{
appURL: string;
visitorCookieTrackingEnabled: boolean;
}>
) {
const { appURL, visitorCookieTrackingEnabled, children } = props;
React.useEffect(() => {
const state = visitorSessionStore.getState();
if (state.pendingSession || state.session) {
return;
}
const pendingSession = fetchSession({ appURL, visitorCookieTrackingEnabled });
visitorSessionStore.setState({ pendingSession, session: null });
pendingSession.then((session) => {
visitorSessionStore.setState({ pendingSession: null, session });
});
}, [appURL, visitorCookieTrackingEnabled]);
return <>{children}</>;
}
/**
* Hook to get the current visitor session.
*/
export function useVisitorSession() {
return useStore(visitorSessionStore, (state) => state.session);
}
/**
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
*/
async function fetchSession({
appURL,
visitorCookieTrackingEnabled,
}: {
appURL: string;
visitorCookieTrackingEnabled: boolean;
}): Promise<SessionResponse> {
const withoutCookies = isCookiesTrackingDisabled();
if (withoutCookies || !visitorCookieTrackingEnabled) {
return {
deviceId: generateRandomId(),
};
}
const { existing, proposedId } = getSessionCookie();
if (existing) {
// If the cookie already exists, we'll just use that. Avoids a server request.
return existing;
}
const url = new URL(appURL);
url.pathname = '/__session/2/';
url.searchParams.set('proposed', proposedId);
try {
const resp = await fetch(url, {
method: 'GET', // Use GET to play nicely with SameSite cookies.
credentials: 'include', // Make sure to send/receive cookies.
cache: 'no-cache',
mode: 'cors', // Need to use cors as we are on a different domain.
});
const session = (await resp.json()) as SessionResponse;
return session;
} catch (error) {
console.error('Failed to fetch visitor session ID', error);
return {
deviceId: proposedId,
};
}
}
function getSessionCookie(): { existing: SessionResponse | null; proposedId: string } {
const proposed = generateRandomId();
const value = getBrowserCookie(VISITORID_COOKIE);
if (!(value && typeof value === 'string')) {
return { existing: null, proposedId: proposed };
}
try {
const parsed = JSON.parse(value) as SessionResponse;
return { existing: parsed, proposedId: proposed };
} catch {
// Ignore legacy __session cookie format
// and we'll renew it with the server using the previous one as a proposed ID
return { existing: null, proposedId: proposed };
}
}
@@ -1,6 +1,6 @@
'use client';
import { CustomizationSearchStyle } from '@gitbook/api';
import { CustomizationSearchStyle, type SiteSection } from '@gitbook/api';
import { useRouter } from 'next/navigation';
import React, { useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
@@ -18,9 +18,27 @@ import { SearchScopeToggle } from './SearchScopeToggle';
import { useSearch } from './useSearch';
interface SearchContainerProps {
/** The current site space id. */
siteSpaceId: string;
/** The title of the current space. */
spaceTitle: string;
isMultiVariants: boolean;
/** The ids of all spaces in the current section. */
siteSpaceIds: string[];
/** Whether there are sections on the site. */
withSections: boolean;
/** The current section, displayed in search scope toggle. */
section?: Pick<SiteSection, 'title' | 'icon'>;
/** Whether the current section has variants. */
withVariants: boolean;
/** Whether any section on the site has variants. */
withSiteVariants: boolean;
style: CustomizationSearchStyle;
className?: string;
viewport?: 'desktop' | 'mobile';
@@ -30,7 +48,18 @@ interface SearchContainerProps {
* Client component to render the search input and results.
*/
export function SearchContainer(props: SearchContainerProps) {
const { siteSpaceId, spaceTitle, isMultiVariants, style, className, viewport } = props;
const {
siteSpaceId,
spaceTitle,
section,
withVariants,
withSiteVariants,
withSections,
style,
className,
viewport,
siteSpaceIds,
} = props;
const { assistants } = useAI();
@@ -108,7 +137,7 @@ export function SearchContainer(props: SearchContainerProps) {
}
setSearchState((prev) => ({
ask: withAI ? (prev?.ask ?? null) : null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'default',
query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '',
open: true,
}));
@@ -148,7 +177,7 @@ export function SearchContainer(props: SearchContainerProps) {
setSearchState((prev) => ({
ask: withAI && !withSearchAI ? (prev?.ask ?? null) : null, // When typing, we reset ask to get back to normal search (unless non-search assistants are defined)
query: value,
global: prev?.global ?? false,
scope: prev?.scope ?? 'default',
open: true,
}));
};
@@ -168,15 +197,22 @@ export function SearchContainer(props: SearchContainerProps) {
// Only show content if there's a query or Ask is enabled
state?.query || withAI ? (
<React.Suspense fallback={null}>
{isMultiVariants && !showAsk ? (
<SearchScopeToggle spaceTitle={spaceTitle} />
{(withVariants || withSections) && !showAsk ? (
<SearchScopeToggle
section={section}
spaceTitle={spaceTitle}
withVariants={withVariants}
withSiteVariants={withSiteVariants}
withSections={withSections}
/>
) : null}
{state !== null && !showAsk ? (
<SearchResults
ref={resultsRef}
query={normalizedQuery}
global={state?.global ?? false}
scope={state?.scope ?? 'default'}
siteSpaceId={siteSpaceId}
siteSpaceIds={siteSpaceIds}
/>
) : null}
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
@@ -194,7 +230,7 @@ export function SearchContainer(props: SearchContainerProps) {
onOpenAutoFocus: (event) => event.preventDefault(),
align: 'start',
className:
'bg-tint-base has-[.empty]:hidden gutter-stable scroll-py-2 w-128 p-2 pr-1 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
'@container bg-tint-base has-[.empty]:hidden scroll-py-2 w-128 p-2 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
onInteractOutside: (event) => {
// Don't close if clicking on the search input itself
if (searchInputRef.current?.contains(event.target as Node)) {
@@ -17,9 +17,11 @@ import { SearchSectionResultItem } from './SearchSectionResultItem';
import {
type OrderedComputedResult,
searchAllSiteContent,
searchSiteSpaceContent,
searchCurrentSiteSpaceContent,
searchSpecificSiteSpaceContent,
streamRecommendedQuestions,
} from './server-actions';
import type { SearchScope } from './useSearch';
export interface SearchResultsRef {
moveUp(): void;
@@ -50,12 +52,13 @@ export const SearchResults = React.forwardRef(function SearchResults(
props: {
children?: React.ReactNode;
query: string;
global: boolean;
scope: SearchScope;
siteSpaceId: string;
siteSpaceIds: string[];
},
ref: React.Ref<SearchResultsRef>
) {
const { children, query, global, siteSpaceId } = props;
const { children, query, scope, siteSpaceId, siteSpaceIds } = props;
const language = useLanguage();
const trackEvent = useTrackEvent();
@@ -133,9 +136,25 @@ export const SearchResults = React.forwardRef(function SearchResults(
setResultsState((prev) => ({ results: prev.results, fetching: true }));
let cancelled = false;
const timeout = setTimeout(async () => {
const results = await (global
? searchAllSiteContent(query)
: searchSiteSpaceContent(query));
const results = await (() => {
if (scope === 'all') {
// Search all content on the site
return searchAllSiteContent(query);
}
if (scope === 'default') {
// Search the current section's variant + matched/default variant for other sections
return searchCurrentSiteSpaceContent(query, siteSpaceId);
}
if (scope === 'extended') {
// Search all variants of the current section
return searchSpecificSiteSpaceContent(query, siteSpaceIds);
}
if (scope === 'current') {
// Search only the current section's current variant
return searchSpecificSiteSpaceContent(query, [siteSpaceId]);
}
throw new Error(`Unhandled search scope: ${scope}`);
})();
if (cancelled) {
return;
@@ -158,7 +177,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
cancelled = true;
clearTimeout(timeout);
};
}, [query, global, trackEvent, withAI, siteSpaceId]);
}, [query, scope, trackEvent, withAI, siteSpaceId, siteSpaceIds]);
const results: ResultType[] = React.useMemo(() => {
if (!withAI) {
@@ -1,13 +1,22 @@
'use client';
import { tString, useLanguage } from '@/intl/client';
import { Button } from '../primitives';
import type { SiteSection } from '@gitbook/api';
import { SegmentedControl, SegmentedControlItem } from '../primitives/SegmentedControl';
import { useSearch } from './useSearch';
/**
* Toolbar to toggle between search modes (global or scoped to a space).
* Only visible when the space is in a collection.
*/
export function SearchScopeToggle(props: { spaceTitle: string }) {
const { spaceTitle } = props;
export function SearchScopeToggle(props: {
spaceTitle: string;
section?: Pick<SiteSection, 'title' | 'icon'>;
withVariants: boolean;
withSiteVariants: boolean;
withSections: boolean;
}) {
const { spaceTitle, section, withVariants, withSections, withSiteVariants } = props;
const [state, setSearchState] = useSearch();
const language = useLanguage();
@@ -16,37 +25,82 @@ export function SearchScopeToggle(props: { spaceTitle: string }) {
}
return (
<div
role="toolbar"
aria-orientation="horizontal"
className="mb-2 flex flex-row flex-wrap gap-1 circular-corners:rounded-3xl rounded-corners:rounded-lg bg-tint-subtle p-1"
>
<Button
variant="blank"
size="medium"
className="shrink grow justify-center whitespace-normal"
active={!state.global}
label={tString(language, 'search_scope_space', spaceTitle)}
onClick={() => {
setSearchState({
...state,
global: false,
});
}}
/>
<Button
variant="blank"
size="medium"
className="shrink grow justify-center whitespace-normal"
active={state.global}
label={tString(language, 'search_scope_all')}
onClick={() => {
setSearchState({
...state,
global: true,
});
}}
/>
</div>
<>
{withSections ? (
<SegmentedControl className="animate-scale-in">
{/* `Default` scope = current section's current variant + best match in other sections */}
<SegmentedControlItem
active={
withSiteVariants
? state.scope === 'default'
: ['default', 'all'].includes(state.scope)
}
label={
withSiteVariants
? tString(language, 'search_scope_default')
: tString(language, 'search_scope_all')
}
className={withSiteVariants ? '@max-md:basis-full' : ''}
icon={withSiteVariants ? 'bullseye-arrow' : 'infinity'}
onClick={() => setSearchState({ ...state, scope: 'default' })}
/>
{/* `Current` scope = current section's current variant (with further variant scope selection if necessary) */}
<SegmentedControlItem
active={state.scope === 'current' || state.scope === 'extended'}
icon={section?.icon ?? 'crosshairs'}
label={tString(language, 'search_scope_current', section?.title)}
onClick={() => setSearchState({ ...state, scope: 'current' })}
/>
{/* `All` scope = all content on the site. Only visible if site has variants, otherwise it's the same as default */}
{withSiteVariants ? (
<SegmentedControlItem
active={state.scope === 'all'}
label={tString(language, 'search_scope_all')}
icon="infinity"
onClick={() => setSearchState({ ...state, scope: 'all' })}
/>
) : null}
</SegmentedControl>
) : null}
{withVariants &&
(!withSections || state.scope === 'current' || state.scope === 'extended') ? (
<SegmentedControl className="animate-scale-in">
{/* `Current` scope = current section's current variant. `Default` on sites without sections. */}
<SegmentedControlItem
size={withSections ? 'small' : 'medium'}
active={
withSections
? state.scope === 'current'
: ['default', 'current'].includes(state.scope)
}
className="py-1"
label={tString(language, 'search_scope_current', spaceTitle)}
onClick={() =>
setSearchState({
...state,
scope: withSections ? 'current' : 'default',
})
}
/>
{/* `Extended` scope = all variants of the current section. `All` on sites without sections. */}
<SegmentedControlItem
size={withSections ? 'small' : 'medium'}
active={
withSections
? state.scope === 'extended'
: ['extended', 'all'].includes(state.scope)
}
className="py-1"
label={tString(language, 'search_scope_extended')}
onClick={() =>
setSearchState({ ...state, scope: withSections ? 'extended' : 'all' })
}
/>
</SegmentedControl>
) : null}
</>
);
}
@@ -81,18 +81,33 @@ export async function searchAllSiteContent(query: string): Promise<OrderedComput
/**
* Server action to search content in a space.
*/
export async function searchSiteSpaceContent(query: string): Promise<OrderedComputedResult[]> {
export async function searchCurrentSiteSpaceContent(
query: string,
siteSpaceId: string
): Promise<OrderedComputedResult[]> {
return traceErrorOnly('Search.searchSiteSpaceContent', async () => {
const context = await getServerActionBaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
return await searchSiteContent(context, {
query,
// If we have a siteSectionId that means its a sections site use `current` mode
// which searches in the current space + all default spaces of sections
scope: siteURLData.siteSection
? { mode: 'current', siteSpaceId: siteURLData.siteSpace }
: { mode: 'specific', siteSpaceIds: [siteURLData.siteSpace] },
scope: { mode: 'current', siteSpaceId },
});
});
}
/**
* Server action to search content in a specific space.
*/
export async function searchSpecificSiteSpaceContent(
query: string,
siteSpaceIds: string[]
): Promise<OrderedComputedResult[]> {
return traceErrorOnly('Search.searchSiteSpaceContent', async () => {
const context = await getServerActionBaseContext();
return await searchSiteContent(context, {
query,
scope: { mode: 'specific', siteSpaceIds },
});
});
}
@@ -1,14 +1,24 @@
'use client';
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs';
import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs';
import React from 'react';
import type { LinkProps } from '../primitives';
export type SearchScope =
/** Search all content on the site */
| 'all'
/** Search the current section's variant + matched/default variant for other sections */
| 'default'
/** Search all variants of the current section */
| 'extended'
/** Search only the current section's current variant */
| 'current';
export interface SearchState {
// URL-backed state
query: string | null;
ask: string | null;
global: boolean;
scope: SearchScope;
// Local UI state
open: boolean;
@@ -18,7 +28,8 @@ export interface SearchState {
const keyMap = {
q: parseAsString,
ask: parseAsString,
global: parseAsBoolean,
scope: parseAsStringLiteral(['all', 'default', 'extended', 'current']).withDefault('default'),
global: parseAsBoolean, // Legacy support for global=true
};
export type UpdateSearchState = (
@@ -41,14 +52,21 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
history: 'replace',
});
// Handle legacy ask=true format by converting it to the new format
React.useEffect(() => {
// Handle legacy ask=true format by converting it to the new format
if (rawState?.ask === 'true' && rawState?.q) {
// Convert legacy format: q=query&ask=true -> ask=query&q=null
setRawState({
q: null,
ask: rawState.q,
global: rawState.global,
});
}
// Handle legacy global=true
if (rawState?.global === true) {
setRawState({
scope: 'all',
global: null, // Remove the legacy parameter
});
}
}, [rawState, setRawState]);
@@ -65,7 +83,7 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
return {
query: rawState.q,
ask: rawState.ask,
global: !!rawState.global,
scope: rawState.scope,
open,
};
}, [rawState, open]);
@@ -85,14 +103,14 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
if (update === null) {
setIsOpen(false);
return setRawState({ q: null, ask: null, global: null });
return setRawState({ q: null, ask: null, scope: 'default' });
}
setIsOpen(update.open);
return setRawState({
q: update.query,
ask: update.ask,
global: update.global ? true : null,
scope: update.scope,
});
},
[setRawState]
@@ -126,7 +144,7 @@ export function useSearchLink(): (
const searchParams = new URLSearchParams();
params.query ? searchParams.set('q', params.query) : searchParams.delete('q');
params.ask ? searchParams.set('ask', params.ask) : searchParams.delete('ask');
params.global ? searchParams.set('global', 'true') : searchParams.delete('global');
params.scope ? searchParams.set('scope', params.scope) : searchParams.delete('scope');
return {
href: `?${searchParams.toString()}`,
prefetch: false,
@@ -137,7 +155,7 @@ export function useSearchLink(): (
...prev,
query: params.query !== undefined ? params.query : null,
ask: params.ask !== undefined ? params.ask : null,
global: params.global !== undefined ? params.global : false,
scope: params.scope !== undefined ? params.scope : 'default',
open: params.open !== undefined ? params.open : false,
}));
},
@@ -21,7 +21,7 @@ import { AIChat } from '../AIChat';
import { AdaptiveVisitorContextProvider } from '../Adaptive';
import { Announcement } from '../Announcement';
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
import { InsightsProvider } from '../Insights';
import { InsightsProvider, VisitorSessionProvider } from '../Insights';
import { SearchContainer } from '../Search';
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
import { CurrentContentProvider } from '../hooks';
@@ -78,16 +78,16 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
revisionId={context.revisionId}
visitorAuthClaims={visitorAuthClaims}
>
<InsightsProvider
enabled={withTracking}
<VisitorSessionProvider
appURL={GITBOOK_APP_URL}
eventUrl={eventUrl.toString()}
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
>
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
{children}
</AIChatProvider>
</InsightsProvider>
<InsightsProvider enabled={withTracking} eventUrl={eventUrl.toString()}>
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
{children}
</AIChatProvider>
</InsightsProvider>
</VisitorSessionProvider>
</CurrentContentProvider>
</AdaptiveVisitorContextProvider>
</SpaceLayoutContextProvider>
@@ -180,9 +180,29 @@ export function SpaceLayout(props: SpaceLayoutProps) {
<div className="flex gap-2">
<SearchContainer
style={CustomizationSearchStyle.Subtle}
isMultiVariants={siteSpaces.length > 1}
withVariants={withVariants === 'generic'}
withSiteVariants={
sections?.list.some(
(s) =>
s.object === 'site-section' &&
s.siteSpaces.filter(
(s) =>
s.space.language ===
siteSpace.space.language
).length > 1
) ?? false
}
withSections={withSections}
section={sections?.current}
spaceTitle={siteSpace.title}
siteSpaceId={siteSpace.id}
siteSpaceIds={siteSpaces
.filter(
(s) =>
s.space.language ===
siteSpace.space.language
)
.map((s) => s.id)}
className="max-lg:hidden"
viewport="desktop"
/>
@@ -40,6 +40,7 @@ export const variantClasses = {
'bg-transparent',
'text-tint',
'border-0',
'contrast-more:border',
'shadow-none!',
'hover:bg-tint-hover',
'hover:text-tint-strong',
@@ -80,9 +81,9 @@ export const variantClasses = {
],
};
const activeClasses = {
export const activeClasses = {
primary: 'bg-primary-solid-hover',
blank: 'bg-primary-active disabled:bg-primary-active text-primary-strong font-medium hover:text-primary-strong disabled:text-primary-strong hover:bg-primary-active',
blank: 'bg-primary-active contrast-more:bg-primary-12 contrast-more:text-contrast-primary-12 disabled:bg-primary-active text-primary-strong font-medium hover:text-primary-strong disabled:text-primary-strong hover:bg-primary-active',
secondary: 'bg-tint-active disabled:bg-tint-active',
header: 'bg-header-link/3',
};
@@ -134,7 +135,7 @@ export const Button = React.forwardRef<
typeof icon === 'string' ? (
<Icon
icon={icon as IconName}
className={tcls('button-leading-icon size-[1em]')}
className={tcls('button-leading-icon size-[1em] shrink-0')}
/>
) : (
icon
@@ -154,6 +155,7 @@ export const Button = React.forwardRef<
aria-label={label?.toString()}
aria-pressed={active}
target={target}
data-active={active}
{...rest}
>
{content}
@@ -166,6 +168,7 @@ export const Button = React.forwardRef<
aria-label={label?.toString()}
aria-pressed={active}
disabled={disabled}
data-active={active}
{...rest}
>
{content}
@@ -3,6 +3,7 @@
import React from 'react';
import { useLanguage } from '@/intl/client';
import { Tooltip } from './Tooltip';
/**
* Display a date as a relative time.
@@ -28,14 +29,11 @@ export function DateRelative(props: { value: string }) {
const date = new Date(value);
const diff = now - date.getTime();
return (
<time
data-visual-test="transparent"
suppressHydrationWarning={true}
dateTime={value}
title={date.toLocaleString()}
>
{formatDiff(language.locale, diff)}
</time>
<Tooltip label={date.toLocaleString()}>
<time data-visual-test="transparent" suppressHydrationWarning={true} dateTime={value}>
{formatDiff(language.locale, diff)}
</time>
</Tooltip>
);
}
@@ -1,6 +1,6 @@
'use client';
import { Icon } from '@gitbook/icons';
import { Icon, type IconName } from '@gitbook/icons';
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
import { createContext, useCallback, useContext, useState } from 'react';
@@ -146,10 +146,20 @@ export function DropdownMenuItem(
active?: boolean;
className?: ClassValue;
children: React.ReactNode;
leadingIcon?: IconName | React.ReactNode;
} & LinkInsightsProps &
RadixDropdownMenu.DropdownMenuItemProps
) {
const { children, active = false, href, className, insights, target, ...rest } = props;
const {
children,
active = false,
href,
className,
insights,
target,
leadingIcon,
...rest
} = props;
const itemClassName = tcls(
'rounded-xs straight-corners:rounded-xs circular-corners:rounded-lg px-3 py-1 text-sm flex gap-2 items-center',
@@ -161,10 +171,22 @@ export function DropdownMenuItem(
className
);
const icon = leadingIcon ? (
typeof leadingIcon === 'string' ? (
<Icon
icon={leadingIcon as IconName}
className={tcls('size-4 shrink-0', active ? 'text-primary' : 'text-tint-subtle')}
/>
) : (
leadingIcon
)
) : null;
if (href) {
return (
<RadixDropdownMenu.Item {...rest} asChild>
<Link href={href} insights={insights} className={itemClassName} target={target}>
{icon}
{children}
</Link>
</RadixDropdownMenu.Item>
@@ -173,6 +195,7 @@ export function DropdownMenuItem(
return (
<RadixDropdownMenu.Item {...rest} className={tcls('px-3 py-1', itemClassName, className)}>
{icon}
{children}
</RadixDropdownMenu.Item>
);
@@ -0,0 +1,35 @@
import { type ClassValue, tcls } from '@/lib/tailwind';
import { Button, type ButtonProps } from './Button';
export function SegmentedControl(props: { children: React.ReactNode; className?: ClassValue }) {
const { children, className } = props;
return (
<div
role="toolbar"
aria-orientation="horizontal"
className={tcls(
'mb-2 flex flex-wrap gap-1 circular-corners:rounded-3xl rounded-corners:rounded-lg bg-tint p-1',
className
)}
>
{children}
</div>
);
}
export function SegmentedControlItem(props: ButtonProps) {
const { size = 'medium', className, ...rest } = props;
return (
<Button
variant="blank"
size={size}
className={tcls(
'shrink grow justify-center whitespace-normal not-contrast-more:data-[active=true]:bg-tint-base',
className
)}
{...rest}
/>
);
}
+4 -2
View File
@@ -12,8 +12,10 @@ export const de = {
search_no_results_for: 'Keine Ergebnisse für "${1}".',
search_no_results: 'Keine Ergebnisse',
search_results_count: '${1} Ergebnisse',
search_scope_space: '${1}',
search_scope_all: 'Alle Inhalte',
search_scope_current: '${1}',
search_scope_extended: 'Alle Inhalte',
search_scope_default: 'Beste Match',
search_scope_all: 'Alles',
ask: 'Fragen',
search_ask: 'Fragen "${1}"',
search_ask_description: 'Finden Sie die Antwort mit ${1}',
+4 -2
View File
@@ -12,8 +12,10 @@ export const en = {
search_no_results_for: 'No results for "${1}".',
search_no_results: 'No results',
search_results_count: '${1} results',
search_scope_space: '${1}',
search_scope_all: 'All content',
search_scope_current: '${1}',
search_scope_extended: 'All content',
search_scope_default: 'Best match',
search_scope_all: 'Everything',
ask: 'Ask',
search_ask: 'Ask "${1}"',
search_ask_description: 'Find the answer with ${1}',
+4 -2
View File
@@ -14,8 +14,10 @@ export const es: TranslationLanguage = {
search_no_results_for: 'No hay resultados para "${1}".',
search_no_results: 'No hay resultados',
search_results_count: '${1} resultados',
search_scope_space: '${1}',
search_scope_all: 'Todo el contenido',
search_scope_current: '${1}',
search_scope_extended: 'Todo el contenido',
search_scope_default: 'Mejor match',
search_scope_all: 'Todo',
ask: 'Preguntar',
search_ask: 'Preguntar "${1}"',
search_ask_description: 'Encuentra la respuesta con ${1}',
+4 -2
View File
@@ -12,8 +12,10 @@ export const fr = {
search_no_results_for: 'Aucun résultat pour « ${1} ».',
search_no_results: 'Aucun résultat',
search_results_count: '${1} résultats',
search_scope_space: '${1}',
search_scope_all: 'Tous les contenus',
search_scope_current: '${1}',
search_scope_extended: 'Tous les contenus',
search_scope_default: 'Meilleur match',
search_scope_all: 'Tout',
ask: 'Poser une question',
search_ask: 'Demander « ${1} »',
search_ask_description: 'Trouvez la réponse avec ${1}',
+4 -2
View File
@@ -14,8 +14,10 @@ export const ja: TranslationLanguage = {
search_no_results_for: '"${1}" の結果はありません。',
search_no_results: '結果がありません',
search_results_count: '${1}件の結果',
search_scope_space: '${1}',
search_scope_all: '全てのコンテンツ',
search_scope_current: '${1}',
search_scope_extended: '全てのコンテンツ',
search_scope_default: '最適なマッチ',
search_scope_all: 'すべて',
ask: '質問する',
search_ask: '"${1}" を質問する',
search_ask_description: '${1}で答えを見つける',
+4 -2
View File
@@ -14,8 +14,10 @@ export const nl: TranslationLanguage = {
search_no_results_for: 'Geen resultaten voor "${1}".',
search_no_results: 'Geen resultaten',
search_results_count: '${1} resultaten',
search_scope_space: '${1}',
search_scope_all: 'Alle inhoud',
search_scope_current: '${1}',
search_scope_extended: 'Alle inhoud',
search_scope_default: 'Beste match',
search_scope_all: 'Alles',
ask: 'Vragen',
search_ask: 'Vraag "${1}"',
search_ask_description: 'Vind het antwoord met ${1}',
+4 -2
View File
@@ -14,8 +14,10 @@ export const no: TranslationLanguage = {
search_no_results_for: 'Ingen resultater for "${1}".',
search_no_results: 'Ingen resultater',
search_results_count: '${1} resultater',
search_scope_space: '${1}',
search_scope_all: 'Alt innhold',
search_scope_current: '${1}',
search_scope_extended: 'Alt innhold',
search_scope_default: 'Beste treff',
search_scope_all: 'Alt',
ask: 'Spør',
search_ask: 'Spør "${1}"',
search_ask_description: 'Finn svaret med ${1}',
@@ -12,8 +12,10 @@ export const pt_br = {
search_no_results_for: 'Sem resultados para "${1}".',
search_no_results: 'Sem resultados',
search_results_count: '${1} resultados',
search_scope_space: '${1}',
search_scope_all: 'Todo o conteúdo',
search_scope_current: '${1}',
search_scope_extended: 'Todo o conteúdo',
search_scope_default: 'Melhor match',
search_scope_all: 'Tudo',
ask: 'Perguntar',
search_ask: 'Perguntar "${1}"',
search_ask_description: 'Encontre a resposta com ${1}',
+4 -2
View File
@@ -12,8 +12,10 @@ export const ru = {
search_no_results_for: 'Нет результатов для "${1}".',
search_no_results: 'Нет результатов',
search_results_count: '${1} — число результатов',
search_scope_space: '${1}',
search_scope_all: 'Все материалы',
search_scope_current: '${1}',
search_scope_extended: 'Все материалы',
search_scope_default: 'Лучший матч',
search_scope_all: 'Всё',
ask: 'Спросить',
search_ask: 'Спросить "${1}"',
search_ask_description: 'Найти ответ с помощью ${1}',
+4 -2
View File
@@ -14,8 +14,10 @@ export const zh: TranslationLanguage = {
search_no_results_for: '没有找到"${1}"的结果。',
search_no_results: '没有找到结果',
search_results_count: '${1} 个结果',
search_scope_space: '${1}',
search_scope_all: '所有内容',
search_scope_current: '${1}',
search_scope_extended: '所有内容',
search_scope_default: '最佳匹配',
search_scope_all: '全部',
ask: '询问',
search_ask: '询问"${1}"',
search_ask_description: '利用${1}找到答案',
+10 -10
View File
@@ -10,23 +10,23 @@ export async function serveRobotsTxt(context: GitBookSiteContext) {
const isRoot = checkIsRootSiteContext(context);
const isIndexable = await isSiteIndexable(context);
const sitemapPath = linker.toPathInSpace(isRoot ? '/sitemap.xml' : '/sitemap-pages.xml');
const sitemapUrl = linker.toAbsoluteURL(sitemapPath);
const lines = isIndexable
? [
'User-agent: *',
// Disallow other dynamic routes / search queries
'Disallow: /*?',
// Allow image resizing and icon generation routes for favicons and search results
// Disallow only internal search
'Disallow: /*?*q=*',
'Disallow: /*?*ask=*',
// Allow dynamic assets (may include ?)
'Allow: /~gitbook/image?*',
'Allow: /~gitbook/icon?*',
'Allow: /favicon.ico',
'Allow: /',
`Sitemap: ${linker.toAbsoluteURL(linker.toPathInSpace(isRoot ? '/sitemap.xml' : '/sitemap-pages.xml'))}`,
`Sitemap: ${sitemapUrl}`,
]
: ['User-agent: *', 'Disallow: /'];
const robotsTxt = lines.join('\n');
return new Response(robotsTxt, {
headers: {
'Content-Type': 'text/plain',
},
});
return new Response(lines.join('\n'), { headers: { 'Content-Type': 'text/plain' } });
}
+3
View File
@@ -81,6 +81,9 @@ const config: Config = {
],
var: ['var(--font-family)'],
},
fontSize: {
xxs: ['0.625rem', { lineHeight: '0.75rem' }],
},
colors: {
// Dynamic colors matching the customization settings