Compare commits

...

22 Commits

Author SHA1 Message Date
Samy Pessé 72f5ce3985 Version Packages (#3480)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-24 13:20:46 +02:00
conico974 04c80262e2 Make sure to use the correct version for adaptive content (#3487)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-24 11:40:12 +02:00
Valentino Hudhra 56d1b8fe2d Fix overflowing recommended follow up questions in assistant (#3502) 2025-07-24 11:03:04 +02:00
Zeno Kapitein 054a1634ab Improve AI Actions dropdown (#3499) 2025-07-24 10:40:13 +02:00
conico974 6ff63f2db6 Don't throw on >500 API error (#3500)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-24 09:41:47 +02:00
Greg Bergé b5ad0ce1e5 Fix OpenAPI v2 parsing (#3498) 2025-07-23 17:21:05 +02:00
Claire Chabas 2ba7e54c81 Fix headings styles in hint block (#3497) 2025-07-23 15:46:44 +02:00
Zeno Kapitein 0a858e74ad Fix React warning (#3496) 2025-07-23 10:56:12 +02:00
Zeno Kapitein b0c534fc2c Tweaks to AI Chat and AI Actions Dropdown (#3495) 2025-07-22 19:18:35 +02:00
Steven H 250c194a02 Add nosnippet to announcement banner. (#3490) 2025-07-22 15:03:35 +01:00
Zeno Kapitein 5b10cde936 Fix background corners showing through Powered through GitBook badge (#3493) 2025-07-22 15:15:38 +02:00
Zeno Kapitein 4aeb81bfa5 Fix search input styling & text cutoff (#3491) 2025-07-22 14:58:20 +02:00
conico974 955cebf944 Add cache tags for fetchFileSystem (#3489)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-22 13:41:31 +02:00
Greg Bergé 7c951ef437 Support new colors in text formatting (#3492) 2025-07-22 13:40:03 +02:00
Brett Jephson 68f0dbcc35 RND-7464: confirm in content kit button (#3488) 2025-07-22 10:51:25 +01:00
Zeno Kapitein 39c4f76aea Add fullwidth page option (#3344) 2025-07-21 11:20:17 +02:00
Zeno Kapitein 2fe58e8cbb Page actions customisation (#3485)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-07-18 21:10:38 +02:00
Zeno Kapitein 765141a4d8 Make "last updated" section optional (#3486) 2025-07-18 18:46:11 +00:00
Zeno Kapitein eb1bd3aa01 AI response feedback buttons (#3482) 2025-07-18 15:40:04 +00:00
Zeno Kapitein b7284b0223 Small tweaks to assistant styling (#3481) 2025-07-17 14:31:53 +00:00
conico974 5bbd254742 Support webp and avif formats for og image (#3478)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-17 11:36:07 +02:00
Nolann B. 611e28626a Remove padding on first Heading in Columns (#3479) 2025-07-16 12:51:37 +02:00
85 changed files with 878 additions and 341 deletions
+7 -7
View File
@@ -48,7 +48,7 @@
}, },
"packages/gitbook": { "packages/gitbook": {
"name": "gitbook", "name": "gitbook",
"version": "0.13.1", "version": "0.14.0",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"@gitbook/cache-tags": "workspace:*", "@gitbook/cache-tags": "workspace:*",
@@ -146,7 +146,7 @@
}, },
"packages/icons": { "packages/icons": {
"name": "@gitbook/icons", "name": "@gitbook/icons",
"version": "0.2.0", "version": "0.2.1",
"bin": { "bin": {
"gitbook-icons": "./bin/gitbook-icons.js", "gitbook-icons": "./bin/gitbook-icons.js",
}, },
@@ -166,7 +166,7 @@
}, },
"packages/openapi-parser": { "packages/openapi-parser": {
"name": "@gitbook/openapi-parser", "name": "@gitbook/openapi-parser",
"version": "2.2.0", "version": "2.2.1",
"dependencies": { "dependencies": {
"@scalar/openapi-parser": "^0.18.0", "@scalar/openapi-parser": "^0.18.0",
"@scalar/openapi-types": "^0.1.9", "@scalar/openapi-types": "^0.1.9",
@@ -181,7 +181,7 @@
}, },
"packages/react-contentkit": { "packages/react-contentkit": {
"name": "@gitbook/react-contentkit", "name": "@gitbook/react-contentkit",
"version": "0.7.1", "version": "0.7.2",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:*", "@gitbook/icons": "workspace:*",
@@ -213,7 +213,7 @@
}, },
"packages/react-openapi": { "packages/react-openapi": {
"name": "@gitbook/react-openapi", "name": "@gitbook/react-openapi",
"version": "1.3.2", "version": "1.3.3",
"dependencies": { "dependencies": {
"@gitbook/openapi-parser": "workspace:*", "@gitbook/openapi-parser": "workspace:*",
"@scalar/api-client-react": "^1.3.16", "@scalar/api-client-react": "^1.3.16",
@@ -246,7 +246,7 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
}, },
"catalog": { "catalog": {
"@gitbook/api": "^0.128.0", "@gitbook/api": "^0.129.0",
}, },
"packages": { "packages": {
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="], "@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="],
@@ -609,7 +609,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="], "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
"@gitbook/api": ["@gitbook/api@0.128.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-VO98hRGfUcFdwMplvFW49jz72/ew2waE7RCu+URKAY2AyPHAduv2zgluF5gF1VcDyuJM9PKFtsdsMtpUjI5sYg=="], "@gitbook/api": ["@gitbook/api@0.129.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-Uh+k/BiDgdXj5a8BIlvwfEXdZNNlQMFDlGKmivUYH1gvFySqrTO6PjR/HtUOZEdNauAeWZmGoB8CLuwrVZS+YA=="],
"@gitbook/cache-tags": ["@gitbook/cache-tags@workspace:packages/cache-tags"], "@gitbook/cache-tags": ["@gitbook/cache-tags@workspace:packages/cache-tags"],
+1 -1
View File
@@ -34,7 +34,7 @@
"workspaces": { "workspaces": {
"packages": ["packages/*"], "packages": ["packages/*"],
"catalog": { "catalog": {
"@gitbook/api": "^0.128.0" "@gitbook/api": "^0.129.0"
} }
}, },
"patchedDependencies": { "patchedDependencies": {
+8 -4
View File
@@ -90,7 +90,7 @@ export function getCacheTag(
| { | {
tag: 'translation'; tag: 'translation';
organization: string; organization: string;
translationSettings: string; translation: string;
} }
): string { ): string {
switch (spec.tag) { switch (spec.tag) {
@@ -115,7 +115,7 @@ export function getCacheTag(
case 'openapi': case 'openapi':
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`; return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
case 'translation': case 'translation':
return `organization:${spec.organization}:translation:${spec.translationSettings}`; return `organization:${spec.organization}:translation:${spec.translation}`;
default: default:
assertNever(spec); assertNever(spec);
} }
@@ -144,6 +144,10 @@ export function getComputedContentSourceCacheTags(
) { ) {
const tags: string[] = []; const tags: string[] = [];
if (!('dependencies' in source)) {
return tags;
}
// We add the dependencies as tags, to ensure that the computed content is invalidated // We add the dependencies as tags, to ensure that the computed content is invalidated
// when the dependencies are updated. // when the dependencies are updated.
const dependencies = Object.values(source.dependencies ?? {}); const dependencies = Object.values(source.dependencies ?? {});
@@ -167,12 +171,12 @@ export function getComputedContentSourceCacheTags(
}) })
); );
break; break;
case 'translation-language': case 'translation':
tags.push( tags.push(
getCacheTag({ getCacheTag({
tag: 'translation', tag: 'translation',
organization: inContext.organizationId, organization: inContext.organizationId,
translationSettings: dependency.ref.translationSettings, translation: dependency.ref.translation,
}) })
); );
break; break;
+17
View File
@@ -1,5 +1,22 @@
# gitbook # gitbook
## 0.14.1
### Patch Changes
- 7c951ef: 'Support new colors in text formatting
- 39c4f76: Add fullwidth page option
- eb1bd3a: AI response feedback buttons
- 2ba7e54: Fix headings styles in hint block
- 250c194: Add nosnippet to announcement banner.
- 4aeb81b: Fix search input styling & text cutoff
- 68f0dbc: Add confirmation modal to contentkit button
- 611e286: Remove padding on first Heading in Columns
- b0c534f: Tweaks to AI Chat and AI Actions Dropdown
- Updated dependencies [b5ad0ce]
- @gitbook/openapi-parser@2.2.2
- @gitbook/react-openapi@1.3.4
## 0.14.0 ## 0.14.0
### Minor Changes ### Minor Changes
+16
View File
@@ -932,6 +932,22 @@ const testCases: TestsCase[] = [
}, },
]), ]),
}, },
{
name: 'Page actions',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: 'Without page actions',
url: getCustomizationURL({
pageActions: {
markdown: false,
externalAI: false,
},
}),
run: waitForCookiesDialog,
},
],
},
{ {
name: 'Ads', name: 'Ads',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/', contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
+4 -4
View File
@@ -318,10 +318,6 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
feedback: { feedback: {
enabled: false, enabled: false,
}, },
// TODO: remove aiSearch once the cache has been fully updated (after 11/07/2025)
aiSearch: {
enabled: true,
},
ai: { ai: {
mode: CustomizationAIMode.None, mode: CustomizationAIMode.None,
}, },
@@ -337,6 +333,10 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
pagination: { pagination: {
enabled: true, enabled: true,
}, },
pageActions: {
externalAI: true,
markdown: true,
},
trademark: { trademark: {
enabled: true, enabled: true,
}, },
+3
View File
@@ -14,6 +14,9 @@ const nextConfig = {
dynamic: 3600, // 1 hour dynamic: 3600, // 1 hour
static: 3600, // 1 hour static: 3600, // 1 hour
}, },
// Since content is fully static, we don't want to fetch on hover again
optimisticClientCache: false,
}, },
env: { env: {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitbook", "name": "gitbook",
"version": "0.14.0", "version": "0.14.1",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
@@ -1,4 +1,5 @@
import type { GitBookSiteContext } from '@/lib/context'; import type { GitBookSiteContext } from '@/lib/context';
import { tcls } from '@/lib/tailwind';
import type { AIMessage } from '@gitbook/api'; import type { AIMessage } from '@gitbook/api';
import { DocumentView } from '../../DocumentView'; import { DocumentView } from '../../DocumentView';
import { AIToolCallsSummary } from './AIToolCallsSummary'; import { AIToolCallsSummary } from './AIToolCallsSummary';
@@ -19,7 +20,13 @@ export function AIMessageView(
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{message.steps.map((step, index) => { {message.steps.map((step, index) => {
return ( return (
<div key={index} className="flex animate-[fadeIn_500ms_both] flex-col gap-2"> <div
key={index}
className={tcls(
'flex animate-fadeIn-slow flex-col gap-2',
step.content.nodes.length > 0 ? 'has-content' : ''
)}
>
<DocumentView <DocumentView
document={step.content} document={step.content}
context={{ context={{
@@ -28,8 +35,9 @@ export function AIMessageView(
wrapBlocksInSuspense: false, wrapBlocksInSuspense: false,
shouldRenderLinkPreviews: true, shouldRenderLinkPreviews: true,
}} }}
style={['space-y-4']} style="mt-2 space-y-4 empty:hidden"
/> />
{renderToolCalls && step.toolCalls && step.toolCalls.length > 0 ? ( {renderToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
<AIToolCallsSummary toolCalls={step.toolCalls} context={context} /> <AIToolCallsSummary toolCalls={step.toolCalls} context={context} />
) : null} ) : null}
@@ -4,6 +4,7 @@ import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate'; import { t } from '@/intl/translate';
import type { GitBookSiteContext } from '@/lib/context'; import type { GitBookSiteContext } from '@/lib/context';
import { resolveContentRef } from '@/lib/references'; import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import type { import type {
AIToolCall, AIToolCall,
AIToolCallGetPageContent, AIToolCallGetPageContent,
@@ -37,7 +38,7 @@ function ToolCallSummary(props: { toolCall: AIToolCall; context: GitBookSiteCont
const { toolCall, context } = props; const { toolCall, context } = props;
return ( return (
<div className="flex items-start gap-2 text-sm text-tint-subtle"> <div className="flex origin-left animate-scaleIn-slow items-start gap-2 text-sm text-tint-subtle">
<Icon <Icon
icon={getIconForToolCall(toolCall)} icon={getIconForToolCall(toolCall)}
className="mt-1 size-3 shrink-0 text-tint-subtle/8" className="mt-1 size-3 shrink-0 text-tint-subtle/8"
@@ -144,69 +145,80 @@ async function DescriptionForSearchToolCall(props: {
}) })
); );
const hasResults = toolCall.results.length > 0;
return ( return (
<details className="-ml-5 group flex w-full flex-col gap-2"> <details className={tcls('-ml-5 group flex w-full flex-col', hasResults ? 'gap-2' : '')}>
<summary className="-mx-2 -mt-2 flex cursor-pointer list-none items-center gap-2 rounded-corners:rounded-md py-2 pr-4 pl-7 transition-colors marker:hidden hover:bg-primary-hover"> <summary
<div className="flex flex-col"> className={tcls(
'-mx-2 flex list-none items-center gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md pr-4 pl-7 transition-colors marker:hidden',
hasResults ? '-my-2 cursor-pointer py-2 hover:bg-primary-hover' : ''
)}
>
<div className="flex flex-col leading-snug">
<p>{t(language, 'searched_for', <strong>{toolCall.query}</strong>)}</p> <p>{t(language, 'searched_for', <strong>{toolCall.query}</strong>)}</p>
<p className="text-tint-subtle text-xs"> <p className="mt-0.5 text-tint-subtle text-xs">
{toolCall.results.length {hasResults
? t(language, 'search_results_count', toolCall.results.length) ? t(language, 'search_results_count', toolCall.results.length)
: t(language, 'search_no_results')} : t(language, 'search_no_results')}
</p> </p>
</div> </div>
<div className="ml-auto flex items-center gap-1"> {hasResults ? (
<span className="block group-open:hidden">{t(language, 'view')}</span> <div className="ml-auto flex items-center gap-1">
<span className="hidden group-open:block">{t(language, 'close')}</span> <span className="block group-open:hidden">{t(language, 'view')}</span>
<Icon <span className="hidden group-open:block">{t(language, 'close')}</span>
icon="chevron-right" <Icon
className="size-3 shrink-0 transition-transform group-open:rotate-90" icon="chevron-right"
/> className="size-3 shrink-0 transition-transform group-open:rotate-90"
</div> />
</div>
) : null}
</summary> </summary>
<div className="max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all duration-500 [transition-behavior:allow-discrete] group-open:max-h-96 group-open:opacity-11"> {hasResults ? (
<ol className="space-y-1"> <div className="mt-1 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all duration-500 [transition-behavior:allow-discrete] group-open:max-h-96 group-open:opacity-11">
{searchResultsWithHrefs.map((result, index) => ( <ol className="space-y-1">
<li {searchResultsWithHrefs.map((result, index) => (
key={`${result.pageId}-${index}`} <li
className="animate-fadeIn" key={`${result.pageId}-${index}`}
style={{ className="animate-fadeIn-slow"
animationDelay: `${index * 25}ms`, style={{
}} animationDelay: `${index * 25}ms`,
> }}
<Link
href={result.href}
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
> >
<Icon <Link
icon="memo" href={result.href}
className="mt-1 size-3 shrink-0 text-tint-subtle" className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
/> >
<div className="flex flex-col gap-1 text-tint"> <Icon
<h3 className="line-clamp-2 font-medium text-sm text-tint"> icon="memo"
<HighlightQuery className="mt-1 size-3 shrink-0 text-tint-subtle"
query={toolCall.query} />
text={result.title} <div className="flex flex-col gap-1 text-tint">
/> <h3 className="line-clamp-2 font-medium text-sm text-tint">
</h3>
{result.description && (
<p className="line-clamp-2 text-tint-subtle text-xs">
<HighlightQuery <HighlightQuery
query={toolCall.query} query={toolCall.query}
text={result.description} text={result.title}
/> />
</p> </h3>
)} {result.description && (
</div> <p className="line-clamp-2 text-tint-subtle text-xs">
<Icon <HighlightQuery
icon="chevron-right" query={toolCall.query}
className="ml-auto size-3 shrink-0 self-center" text={result.description}
/> />
</Link> </p>
</li> )}
))} </div>
</ol> <Icon
</div> icon="chevron-right"
className="ml-auto size-3 shrink-0 self-center"
/>
</Link>
</li>
))}
</ol>
</div>
) : null}
</details> </details>
); );
} }
@@ -11,6 +11,7 @@ import { useAIMessageContextRef } from './useAIMessageContext';
export type AIChatMessage = { export type AIChatMessage = {
role: AIMessageRole; role: AIMessageRole;
content: React.ReactNode; content: React.ReactNode;
query?: string;
}; };
export type AIChatState = { export type AIChatState = {
@@ -24,6 +25,11 @@ export type AIChatState = {
*/ */
responseId: string | null; responseId: string | null;
/**
* The latest query sent to the AI.
*/
query: string | null;
/** /**
* Messages in the session. * Messages in the session.
*/ */
@@ -73,6 +79,7 @@ const globalState = zustand.create<{
opened: false, opened: false,
responseId: null, responseId: null,
messages: [], messages: [],
query: null,
followUpSuggestions: [], followUpSuggestions: [],
loading: false, loading: false,
error: false, error: false,
@@ -106,6 +113,7 @@ export function useAIChatController(): AIChatController {
opened: state.opened, opened: state.opened,
loading: false, loading: false,
messages: [], messages: [],
query: null,
followUpSuggestions: [], followUpSuggestions: [],
responseId: null, responseId: null,
error: false, error: false,
@@ -128,6 +136,7 @@ export function useAIChatController(): AIChatController {
content: null, content: null,
}, },
], ],
query: input.message,
followUpSuggestions: [], followUpSuggestions: [],
loading: true, loading: true,
error: false, error: false,
@@ -232,7 +232,7 @@ function AIActionWrapper(props: {
size="xsmall" size="xsmall"
variant="secondary" variant="secondary"
label={shortLabel || label} label={shortLabel || label}
className="hover:!scale-100 !shadow-none !rounded-r-none hover:!translate-y-0 border-r-0 bg-tint-base text-sm" className="bg-tint-base text-sm"
onClick={onClick} onClick={onClick}
href={href} href={href}
target={href ? '_blank' : undefined} target={href ? '_blank' : undefined}
@@ -6,8 +6,9 @@ import {
OpenInLLM, OpenInLLM,
ViewAsMarkdown, ViewAsMarkdown,
} from '@/components/AIActions/AIActions'; } from '@/components/AIActions/AIActions';
import { Button } from '@/components/primitives/Button'; import { Button, ButtonGroup } from '@/components/primitives/Button';
import { DropdownMenu } from '@/components/primitives/DropdownMenu'; import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
import type { SiteCustomizationSettings } from '@gitbook/api';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
import { useRef } from 'react'; import { useRef } from 'react';
@@ -16,10 +17,7 @@ interface AIActionsDropdownProps {
markdownPageUrl: string; markdownPageUrl: string;
withAIChat?: boolean; withAIChat?: boolean;
trademark: boolean; trademark: boolean;
/** actions: SiteCustomizationSettings['pageActions'];
* Whether to include the "Open in LLM" entries in the dropdown menu.
*/
withLLMActions?: boolean;
} }
/** /**
@@ -29,29 +27,31 @@ export function AIActionsDropdown(props: AIActionsDropdownProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
return ( return (
<div ref={ref} className="flex h-fit items-stretch justify-start"> <ButtonGroup ref={ref}>
<DefaultAction {...props} /> <DefaultAction {...props} />
<DropdownMenu {props.actions.markdown || props.actions.externalAI ? (
align="end" <DropdownMenu
className="!min-w-60 max-w-max" align="end"
button={ className="!min-w-60 max-w-max"
<Button button={
icon={ <Button
<Icon icon={
icon="chevron-down" <Icon
className="size-3 transition-transform group-data-[state=open]/button:rotate-180" icon="chevron-down"
/> className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
} />
iconOnly }
size="xsmall" iconOnly
variant="secondary" size="xsmall"
className="hover:!scale-100 hover:!translate-y-0 !shadow-none !rounded-l-none bg-tint-base text-sm" variant="secondary"
/> className="bg-tint-base text-sm"
} />
> }
<AIActionsDropdownMenuContent {...props} /> >
</DropdownMenu> <AIActionsDropdownMenuContent {...props} />
</div> </DropdownMenu>
) : null}
</ButtonGroup>
); );
} }
@@ -59,7 +59,7 @@ export function AIActionsDropdown(props: AIActionsDropdownProps) {
* The content of the dropdown menu. * The content of the dropdown menu.
*/ */
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) { function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
const { markdownPageUrl, withAIChat, trademark, withLLMActions } = props; const { markdownPageUrl, withAIChat, trademark, actions } = props;
return ( return (
<> <>
@@ -67,15 +67,21 @@ function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
<OpenDocsAssistant trademark={trademark} type="dropdown-menu-item" /> <OpenDocsAssistant trademark={trademark} type="dropdown-menu-item" />
) : null} ) : null}
<CopyMarkdown {actions.markdown ? (
isDefaultAction={!withAIChat}
markdownPageUrl={markdownPageUrl}
type="dropdown-menu-item"
/>
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
{withLLMActions ? (
<> <>
<DropdownMenuSeparator className="first:hidden" />
<CopyMarkdown
isDefaultAction={!withAIChat}
markdownPageUrl={markdownPageUrl}
type="dropdown-menu-item"
/>
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
</>
) : null}
{actions.externalAI ? (
<>
<DropdownMenuSeparator className="first:hidden" />
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="dropdown-menu-item" /> <OpenInLLM provider="chatgpt" url={markdownPageUrl} type="dropdown-menu-item" />
<OpenInLLM provider="claude" url={markdownPageUrl} type="dropdown-menu-item" /> <OpenInLLM provider="claude" url={markdownPageUrl} type="dropdown-menu-item" />
</> </>
@@ -88,17 +94,27 @@ function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
* A default action shown as a quick-access button beside the dropdown menu * A default action shown as a quick-access button beside the dropdown menu
*/ */
function DefaultAction(props: AIActionsDropdownProps) { function DefaultAction(props: AIActionsDropdownProps) {
const { markdownPageUrl, withAIChat, trademark } = props; const { markdownPageUrl, withAIChat, trademark, actions } = props;
if (withAIChat) { if (withAIChat) {
return <OpenDocsAssistant trademark={trademark} type="button" />; return <OpenDocsAssistant trademark={trademark} type="button" />;
} }
return ( if (actions.markdown) {
<CopyMarkdown return (
isDefaultAction={!withAIChat} <CopyMarkdown
markdownPageUrl={markdownPageUrl} isDefaultAction={!withAIChat}
type="button" markdownPageUrl={markdownPageUrl}
/> type="button"
); />
);
}
if (actions.externalAI) {
return (
<>
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="button" />
</>
);
}
} }
@@ -112,7 +112,7 @@ export function AIChatWindow(props: {
ref={containerRef} ref={containerRef}
> >
<div className="relative flex h-full grow flex-col overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-md bg-tint-base text-sm text-tint depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle"> <div className="relative flex h-full grow flex-col overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-md bg-tint-base text-sm text-tint depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
<div className="flex items-center gap-2 border-tint-subtle border-b bg-tint-subtle px-4 py-2 text-tint-strong"> <div className="flex select-none items-center gap-2 border-tint-subtle border-b bg-tint-subtle px-4 py-2 text-tint-strong">
<AIChatIcon <AIChatIcon
className="size-5 text-tint" className="size-5 text-tint"
trademark={trademark} trademark={trademark}
@@ -149,7 +149,7 @@ export function AIChatWindow(props: {
onClick={() => {}} onClick={() => {}}
iconOnly iconOnly
icon="ellipsis" icon="ellipsis"
label={tString(language, 'more')} label={tString(language, 'actions')}
className="!px-2" className="!px-2"
variant="blank" variant="blank"
size="default" size="default"
@@ -162,7 +162,10 @@ export function AIChatWindow(props: {
}} }}
disabled={isEmpty} disabled={isEmpty}
> >
<Icon icon="broom-wide" className="size-3 text-tint-subtle" /> <Icon
icon="broom-wide"
className="size-3 shrink-0 text-tint-subtle"
/>
{t(language, 'ai_chat_clear_conversation')} {t(language, 'ai_chat_clear_conversation')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenu> </DropdownMenu>
@@ -186,7 +189,7 @@ export function AIChatWindow(props: {
> >
{isEmpty ? ( {isEmpty ? (
<div className="flex min-h-full w-full shrink-0 flex-col items-center justify-center gap-6 py-4"> <div className="flex min-h-full w-full shrink-0 flex-col items-center justify-center gap-6 py-4">
<div className="flex size-32 animate-[fadeIn_500ms_both] items-center justify-center rounded-full bg-tint-subtle"> <div className="flex size-32 animate-fadeIn-slow items-center justify-center rounded-full bg-tint-subtle">
<AIChatIcon <AIChatIcon
state="intro" state="intro"
trademark={trademark} trademark={trademark}
@@ -215,7 +218,7 @@ export function AIChatWindow(props: {
</div> </div>
<div <div
ref={inputRef} ref={inputRef}
className="absolute inset-x-0 bottom-0 mr-2 flex flex-col gap-4 bg-gradient-to-b from-transparent to-50% to-tint-base/9 p-4 pr-2" className="absolute inset-x-0 bottom-0 mr-2 flex select-none flex-col gap-4 bg-gradient-to-b from-transparent to-50% to-tint-base/9 p-4 pr-2"
> >
{/* Display an error banner when something went wrong. */} {/* Display an error banner when something went wrong. */}
{chat.error ? <AIChatError chatController={chatController} /> : null} {chat.error ? <AIChatError chatController={chatController} /> : null}
@@ -241,7 +244,7 @@ function AIChatError(props: { chatController: AIChatController }) {
const { chatController } = props; const { chatController } = props;
return ( return (
<div className="flex flex-wrap justify-between gap-2 rounded-md bg-danger p-3 text-danger text-sm ring-1 ring-danger"> <div className="flex animate-scaleIn flex-wrap justify-between gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md bg-danger px-3 py-2 text-danger text-sm ring-1 ring-danger">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon icon="exclamation-triangle" className="size-3.5" /> <Icon icon="exclamation-triangle" className="size-3.5" />
<span className="flex items-center gap-1">{t(language, 'ai_chat_error')}</span> <span className="flex items-center gap-1">{t(language, 'ai_chat_error')}</span>
@@ -1,4 +1,6 @@
'use client'; 'use client';
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { useAIChatController, useAIChatState } from '../AI/useAIChat'; import { useAIChatController, useAIChatState } from '../AI/useAIChat';
import { Button } from '../primitives'; import { Button } from '../primitives';
@@ -13,6 +15,7 @@ export function AIChatButton(props: { trademark: boolean }) {
const { trademark } = props; const { trademark } = props;
const chatController = useAIChatController(); const chatController = useAIChatController();
const chat = useAIChatState(); const chat = useAIChatState();
const language = useLanguage();
return ( return (
<Button <Button
@@ -23,7 +26,7 @@ export function AIChatButton(props: { trademark: boolean }) {
className={tcls('h-9 px-2.5')} className={tcls('h-9 px-2.5')}
label={ label={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{getAIChatName(trademark)} {t(language, 'ai_chat_ask', getAIChatName(trademark))}
<KeyboardShortcut keys={['mod', 'j']} className="border-tint-11 text-tint-1" /> <KeyboardShortcut keys={['mod', 'j']} className="border-tint-11 text-tint-1" />
</div> </div>
} }
@@ -54,7 +54,7 @@ export const AIChatIcon = ({
state === 'intro' && state === 'intro' &&
'animate-[fadeIn_.5s_.7s_both,spin_2s_1s_forwards_cubic-bezier(.43,1.54,.64,1)]', 'animate-[fadeIn_.5s_.7s_both,spin_2s_1s_forwards_cubic-bezier(.43,1.54,.64,1)]',
(state === 'working' || state === 'thinking') && (state === 'working' || state === 'thinking') &&
'animate-[fadeIn_.5s_.3s_both,spin_2s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]', 'animate-[fadeIn_.5s_.3s_both,spin_2s_1s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
state === 'done' && 'animate-[fadeOut_.5s_both]', state === 'done' && 'animate-[fadeOut_.5s_both]',
state === 'default' && 'animate-[fadeIn_0s_both]', state === 'default' && 'animate-[fadeIn_0s_both]',
state === 'error' && 'hidden' state === 'error' && 'hidden'
@@ -64,7 +64,7 @@ export function AIChatInput(props: {
'focus:outline-none', 'focus:outline-none',
'focus:ring-0', 'focus:ring-0',
'w-full', 'w-full',
'px-4', 'px-3',
'py-3', 'py-3',
'pb-12', 'pb-12',
'h-auto', 'h-auto',
@@ -98,7 +98,7 @@ export function AIChatInput(props: {
} }
}} }}
/> />
<div className="absolute top-2.5 right-4 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden"> <div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
<KeyboardShortcut keys={['mod', 'j']} /> <KeyboardShortcut keys={['mod', 'j']} />
</div> </div>
<div className="absolute inset-x-0 bottom-0 flex items-center px-2 py-2"> <div className="absolute inset-x-0 bottom-0 flex items-center px-2 py-2">
@@ -2,6 +2,7 @@ import { tcls } from '@/lib/tailwind';
import { AIMessageRole } from '@gitbook/api'; import { AIMessageRole } from '@gitbook/api';
import type React from 'react'; import type React from 'react';
import type { AIChatController, AIChatState } from '../AI/useAIChat'; import type { AIChatController, AIChatState } from '../AI/useAIChat';
import { AIResponseFeedback } from './AIResponseFeedback';
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions'; import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
export function AIChatMessages(props: { export function AIChatMessages(props: {
@@ -23,29 +24,30 @@ export function AIChatMessages(props: {
<div <div
ref={isLastUserMessage ? lastUserMessageRef : undefined} ref={isLastUserMessage ? lastUserMessageRef : undefined}
className={tcls( className={tcls(
message.content ? 'animate-[fadeIn_500ms_both]' : '', message.content ? 'animate-fadeIn-slow' : '',
'shrink-0', 'shrink-0',
'last:min-h-[calc(100%-5rem)]', 'last:min-h-[calc(100%-5rem)]',
'scroll-mt-36', 'scroll-mt-36',
'lg:scroll-mt-0', 'lg:scroll-mt-0',
'flex flex-col gap-6', 'flex flex-col gap-6',
'group/message',
message.role === AIMessageRole.User message.role === AIMessageRole.User
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2' ? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
: '' : 'text-tint-strong'
)} )}
style={{ style={{
animationDelay: `${Math.min(index * 0.05, 0.5)}s`, animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
}} }}
key={index} key={index}
> >
{message.content ? ( {message.content ? message.content : null}
message.content
) : chat.loading ? ( {isLastMessage && chat.loading ? (
<div className="flex w-full animate-[fadeIn_500ms_both] flex-wrap gap-2"> <div className="flex w-full animate-fadeIn-slow flex-wrap gap-2 group-has-[.has-content]/message:hidden">
{Array.from({ length: 7 }).map((_, index) => ( {Array.from({ length: 7 }).map((_, index) => (
<div <div
key={index} key={index}
className="h-4 animate-[fadeIn_0.5s_ease_both,pulse_1.5s_infinite] rounded-md bg-tint-4" className="h-4 animate-[fadeIn_500ms_ease_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-4"
style={{ style={{
width: `calc(${(index % 4) * 20 + 10}% - 4px)`, width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
animationDelay: `${index * 0.1}s`, animationDelay: `${index * 0.1}s`,
@@ -54,11 +56,21 @@ export function AIChatMessages(props: {
))} ))}
</div> </div>
) : null} ) : null}
{isLastMessage ? ( {isLastMessage ? (
<AIChatFollowupSuggestions <>
chat={chat} {!chat.loading && !chat.error && chat.query && chat.responseId && (
chatController={chatController} <AIResponseFeedback
/> responseId={chat.responseId}
query={chat.query}
className="-ml-1 -mt-4"
/>
)}
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
/>
</>
) : null} ) : null}
</div> </div>
); );
@@ -0,0 +1,70 @@
'use client';
import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { useState } from 'react';
import { useTrackEvent } from '../Insights';
import { Button } from '../primitives';
export function AIResponseFeedback(props: {
className?: ClassValue;
responseId: string;
query: string;
}) {
const { className, responseId, query } = props;
const language = useLanguage();
const [rating, setRating] = useState<1 | -1 | null>(null);
const trackEvent = useTrackEvent();
const handleRating = (rating: 1 | -1) => {
setRating(rating);
trackEvent({ type: 'ask_rate_response', query, responseId, rating });
};
return (
<div className={tcls('flex h-fit items-center', className)}>
<Button
icon="thumbs-up"
iconOnly
label={tString(language, 'was_this_helpful_positive_label')}
variant="blank"
className={tcls(
'animate-fadeIn overflow-hidden text-tint-subtle transition-all',
rating !== null && rating !== 1 && 'px-0 text-[0] opacity-0'
)}
size="medium"
style={{ animationDuration: '.5s' }}
onClick={() => handleRating(1)}
disabled={rating !== null}
active={rating === 1}
key="positive"
/>
<Button
icon="thumbs-down"
iconOnly
label={tString(language, 'was_this_helpful_negative_label')}
variant="blank"
className={tcls(
'animate-fadeIn overflow-hidden text-tint-subtle transition-all',
rating !== null && rating !== -1 && 'px-0 text-[0] opacity-0'
)}
size="medium"
style={{ animationDelay: '.2s', animationDuration: '.5s' }}
onClick={() => handleRating(-1)}
disabled={rating !== null}
active={rating === -1}
key="negative"
/>
{rating !== null ? (
<span
className="ml-2 animate-fadeIn-slow text-tint-subtle"
style={{ animationDelay: '.3s' }}
>
{t(language, 'was_this_helpful_thank_you')}
</span>
) : null}
</div>
);
}
@@ -15,7 +15,7 @@ export function AIChatFollowupSuggestions(props: {
} }
return ( return (
<div className="mt-auto flex flex-row flex-wrap justify-end gap-2"> <div className="mt-auto flex w-full flex-wrap justify-end gap-2">
{chat.followUpSuggestions.map((suggestion, index) => ( {chat.followUpSuggestions.map((suggestion, index) => (
<Button <Button
key={index} key={index}
@@ -23,7 +23,7 @@ export function AIChatFollowupSuggestions(props: {
chatController.postMessage({ message: suggestion }); chatController.postMessage({ message: suggestion });
}} }}
label={suggestion} label={suggestion}
className="max-w-[80%] animate-[present_500ms_both] whitespace-normal text-left ring-1 ring-tint-subtle" className="!whitespace-normal max-w-full animate-[present_500ms_both] text-left ring-1 ring-tint-subtle"
size="medium" size="medium"
variant="blank" variant="blank"
style={{ style={{
@@ -1,3 +1,4 @@
export * from './AIChat'; export * from './AIChat';
export * from './AIChatButton'; export * from './AIChatButton';
export * from './AIChatIcon'; export * from './AIChatIcon';
export * from './AIResponseFeedback';
@@ -29,7 +29,11 @@ export function AnnouncementBanner(props: {
const style = BANNER_STYLES[announcement.style]; const style = BANNER_STYLES[announcement.style];
return ( return (
<div id="announcement-banner" className="theme-bold:bg-header-background pt-4 pb-2"> <div
id="announcement-banner"
className="theme-bold:bg-header-background pt-4 pb-2"
data-nosnippet=""
>
<div className="scroll-nojump"> <div className="scroll-nojump">
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96"> <div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className={tcls('relative', CONTAINER_STYLE)}> <div className={tcls('relative', CONTAINER_STYLE)}>
@@ -47,6 +47,23 @@ type UnwrappedBlocksProps<TBlock extends DocumentBlock> = DocumentContextProps &
isOffscreen?: boolean; isOffscreen?: boolean;
}; };
/* Blocks that can be full width are automatically expanded on full-width pages.
* Ideally we'd rely on the block type to determine if it can be full width, but
* the block's `fullWidth` property does not differentiate between `undefined` and `false`.
* So instead we hardcode a list of blocks that can be full width. */
const FULL_WIDTH_BLOCKS = [
'table',
'tabs',
'integration',
'openapi',
'images',
'embed',
'columns',
'code',
'content-ref',
'hint',
];
/** /**
* Renders a list of blocks without a wrapper element. * Renders a list of blocks without a wrapper element.
*/ */
@@ -68,10 +85,11 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
key={node.key || `${node.type}-${index}`} key={node.key || `${node.type}-${index}`}
block={node} block={node}
style={[ style={[
'mx-auto w-full decoration-primary/6', 'mx-auto page-width-wide:mx-0 w-full decoration-primary/6',
node.data && 'fullWidth' in node.data && node.data.fullWidth node.data && 'fullWidth' in node.data && node.data.fullWidth
? 'max-w-screen-2xl' ? 'max-w-screen-2xl'
: 'page-full-width:ml-0 max-w-3xl', : 'max-w-3xl',
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-screen-2xl',
blockStyle, blockStyle,
]} ]}
isEstimatedOffscreen={isOffscreen} isEstimatedOffscreen={isOffscreen}
@@ -18,7 +18,7 @@ export function Columns(props: BlockProps<DocumentBlockColumns>) {
document={document} document={document}
ancestorBlocks={[...ancestorBlocks, block, columnBlock]} ancestorBlocks={[...ancestorBlocks, block, columnBlock]}
context={context} context={context}
style="w-full space-y-4 *:max-w-full" style="group/column w-full space-y-4 *:max-w-full"
/> />
</Column> </Column>
); );
@@ -7,5 +7,5 @@ import type { BlockProps } from './Block';
export function Divider(props: BlockProps<DocumentBlockDivider>) { export function Divider(props: BlockProps<DocumentBlockDivider>) {
const { style } = props; const { style } = props;
return <hr className={tcls(style, 'page-full-width:max-w-full border-tint-subtle')} />; return <hr className={tcls(style, 'page-width-wide:max-w-full border-tint-subtle')} />;
} }
@@ -24,28 +24,32 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
className={tcls( className={tcls(
textStyle.textSize, textStyle.textSize,
'heading', 'heading',
'grid', 'flex',
'items-baseline',
'scroll-m-12', 'scroll-m-12',
getTextAlignment(block.data.align),
hashLinkButtonWrapperStyles, hashLinkButtonWrapperStyles,
style style,
textStyle.marginTop
)} )}
> >
<HashLinkButton <HashLinkButton
id={id} id={id}
block={block} block={block}
className={tcls('-ml-6', textStyle.anchorButtonMarginTop)} className={tcls('-ml-6', 'pr-2')}
iconClassName={tcls('size-4')} iconClassName={tcls('size-4')}
label="Direct link to heading" label="Direct link to heading"
/> />
<div <div
className={tcls( className={tcls(
'grid-area-1-1', 'flex-1',
'z-[1]', 'z-[1]',
'justify-self-start', 'justify-self-start',
'max-w-full',
'break-words',
getTextAlignment(block.data.align), getTextAlignment(block.data.align),
textStyle.lineHeight, textStyle.lineHeight
textStyle.marginTop
)} )}
> >
<Inlines {...rest} context={context} nodes={block.nodes} ancestorInlines={[]} /> <Inlines {...rest} context={context} nodes={block.nodes} ancestorInlines={[]} />
@@ -52,8 +52,10 @@ export function Hint(props: BlockProps<DocumentBlockHint>) {
{hasHeading ? ( {hasHeading ? (
<Block <Block
style={tcls( style={tcls(
'flip-heading-hash p-4 pl-3 text-[1em] *:mt-0', '!py-4 w-full items-start pl-3 text-[1em] *:flex-none',
hasHeading ? hintStyle.header : null // Heading hash styles
'flip-heading-hash pr-8',
hintStyle.header
)} )}
ancestorBlocks={[...ancestorBlocks, block]} ancestorBlocks={[...ancestorBlocks, block]}
{...contextProps} {...contextProps}
@@ -195,3 +195,18 @@
.contentkit-hstack > .contentkit-divider-large { .contentkit-hstack > .contentkit-divider-large {
@apply mx-4; @apply mx-4;
} }
/** Confirmation modal */
.contentkit-modal-confirm {
@apply p-4;
}
.contentkit-modal-confirm .contentkit-modal-footer {
@apply flex gap-2 justify-end;
}
.contentkit-button-confirm {
@apply w-auto flex-grow-0 flex-shrink-0;
}
.contentkit-button-style-danger {
@apply bg-danger text-danger hover:bg-danger-hover hover:text-danger-strong contrast-more:bg-tint-subtle;
}
@@ -35,9 +35,9 @@ export function ListItem(props: BlockProps<DocumentBlockListItem>) {
// flip heading hash icon if list item is a heading // flip heading hash icon if list item is a heading
'flip-heading-hash', 'flip-heading-hash',
// remove margin-top for the first heading in a list // remove margin-top for the first heading in a list
'[&:is(h2)>div]:mt-0', '[&:is(h2)]:pt-0',
'[&:is(h3)>div]:mt-0', '[&:is(h3)]:pt-0',
'[&:is(h4)>div]:mt-0', '[&:is(h4)]:pt-0',
// Override the "mx-auto" class from UnwrappedBlocks // Override the "mx-auto" class from UnwrappedBlocks
'mx-0' 'mx-0'
)} )}
@@ -61,7 +61,7 @@ export function getOpenAPIContext(args: {
style={tcls([ style={tcls([
headingProps.deprecated ? 'line-through' : undefined, headingProps.deprecated ? 'line-through' : undefined,
headingProps.deprecated || !!headingProps.stability headingProps.deprecated || !!headingProps.stability
? '[&>div]:mt-0' ? '[&>div]:pt-0'
: undefined, : undefined,
])} ])}
block={{ block={{
@@ -58,7 +58,7 @@ export async function RecordCard(
'[&_.heading>div:first-child]:hidden', '[&_.heading>div:first-child]:hidden',
'[&_.heading>div]:text-[.8em]', '[&_.heading>div]:text-[.8em]',
'md:[&_.heading>div]:text-[1em]', 'md:[&_.heading>div]:text-[1em]',
'[&_.blocks:first-child_.heading:first-child_div]:mt-0', // Remove margin on first heading in card '[&_.blocks:first-child_.heading]:pt-0', // Remove padding-top on first heading in card
// On mobile, check if we can display the cover responsively or not: // On mobile, check if we can display the cover responsively or not:
// - If the file has a landscape aspect ratio, we display it normally // - If the file has a landscape aspect ratio, we display it normally
@@ -126,7 +126,25 @@ function Color(props: MarkedLeafProps<DocumentMarkColor>) {
); );
} }
const textColorToStyle: { [color in DocumentMarkColor['data']['text']]: ClassValue } = { /**
* @TODO replace by DocumentMarkColor['data']['text'] and DocumentMarkColor['data']['background']
* once the API is updated.
*/
type DocumentMarkColorValue =
| 'default'
| 'green'
| 'blue'
| 'red'
| 'orange'
| 'yellow'
| 'purple'
| '$primary'
| '$info'
| '$success'
| '$warning'
| '$danger';
const textColorToStyle: { [color in DocumentMarkColorValue]: ClassValue } = {
default: [], default: [],
blue: ['text-blue-500'], blue: ['text-blue-500'],
red: ['text-red-500'], red: ['text-red-500'],
@@ -134,9 +152,14 @@ const textColorToStyle: { [color in DocumentMarkColor['data']['text']]: ClassVal
yellow: ['text-yellow-600'], yellow: ['text-yellow-600'],
purple: ['text-purple-500'], purple: ['text-purple-500'],
orange: ['text-orange-500'], orange: ['text-orange-500'],
$primary: ['text-primary'],
$info: ['text-info'],
$success: ['text-success'],
$warning: ['text-warning'],
$danger: ['text-danger'],
}; };
const backgroundColorToStyle: { [color in DocumentMarkColor['data']['background']]: ClassValue } = { const backgroundColorToStyle: { [color in DocumentMarkColorValue]: ClassValue } = {
default: [], default: [],
blue: ['bg-mark-blue'], blue: ['bg-mark-blue'],
red: ['bg-mark-red'], red: ['bg-mark-red'],
@@ -144,4 +167,9 @@ const backgroundColorToStyle: { [color in DocumentMarkColor['data']['background'
yellow: ['bg-mark-yellow'], yellow: ['bg-mark-yellow'],
purple: ['bg-mark-purple'], purple: ['bg-mark-purple'],
orange: ['bg-mark-orange'], orange: ['bg-mark-orange'],
$primary: ['bg-primary'],
$info: ['bg-info'],
$success: ['bg-success'],
$warning: ['bg-warning'],
$danger: ['bg-danger'],
}; };
@@ -10,8 +10,6 @@ export function getBlockTextStyle(block: DocumentBlock): {
lineHeight: string; lineHeight: string;
/** Tailwind class for the margin top (mt-*) */ /** Tailwind class for the margin top (mt-*) */
marginTop?: string; marginTop?: string;
/** Tailwind class for the margin top to apply on the anchor link button */
anchorButtonMarginTop?: string;
} { } {
switch (block.type) { switch (block.type) {
case 'paragraph': case 'paragraph':
@@ -23,22 +21,19 @@ export function getBlockTextStyle(block: DocumentBlock): {
return { return {
textSize: 'text-3xl font-semibold', textSize: 'text-3xl font-semibold',
lineHeight: 'leading-tight', lineHeight: 'leading-tight',
marginTop: 'mt-[1em]', marginTop: 'column-first-of-type:pt-0 pt-[1em]',
anchorButtonMarginTop: 'mt-[1.05em]',
}; };
case 'heading-2': case 'heading-2':
return { return {
textSize: 'text-2xl font-semibold', textSize: 'text-2xl font-semibold',
lineHeight: 'leading-snug', lineHeight: 'leading-snug',
marginTop: 'mt-[0.75em]', marginTop: 'column-first-of-type:pt-0 pt-[0.75em]',
anchorButtonMarginTop: 'mt-[0.9em]',
}; };
case 'heading-3': case 'heading-3':
return { return {
textSize: 'text-xl font-semibold', textSize: 'text-xl font-semibold',
lineHeight: 'leading-snug', lineHeight: 'leading-snug',
marginTop: 'mt-[0.5em]', marginTop: 'column-first-of-type:pt-0 pt-[0.5em]',
anchorButtonMarginTop: 'mt-[0.65em]',
}; };
case 'divider': case 'divider':
return { return {
@@ -36,16 +36,16 @@ export function Footer(props: { context: GitBookSiteContext }) {
<div className={tcls(CONTAINER_STYLE, 'px-4', 'py-8', 'lg:py-12', 'mx-auto')}> <div className={tcls(CONTAINER_STYLE, 'px-4', 'py-8', 'lg:py-12', 'mx-auto')}>
<div <div
className={tcls( className={tcls(
'lg:!max-w-none mx-auto grid max-w-3xl site-full-width:max-w-screen-2xl justify-between gap-12', 'lg:!max-w-none mx-auto grid max-w-3xl site-width-wide:max-w-screen-2xl justify-between gap-12',
'grid-cols-[auto_auto]', 'grid-cols-[auto_auto]',
'lg:grid-cols-[18rem_minmax(auto,_48rem)_auto]', 'lg:grid-cols-[18rem_minmax(auto,_48rem)_auto]',
'xl:grid-cols-[18rem_minmax(auto,_48rem)_14rem]', 'xl:grid-cols-[18rem_minmax(auto,_48rem)_14rem]',
'site-full-width:lg:grid-cols-[18rem_minmax(auto,_80rem)_auto]', 'site-width-wide:lg:grid-cols-[18rem_minmax(auto,_80rem)_auto]',
'site-full-width:xl:grid-cols-[18rem_minmax(auto,_80rem)_14rem]', 'site-width-wide:xl:grid-cols-[18rem_minmax(auto,_80rem)_14rem]',
'page-no-toc:lg:grid-cols-[minmax(auto,_48rem)_auto]', 'page-no-toc:lg:grid-cols-[minmax(auto,_48rem)_auto]',
'page-no-toc:xl:grid-cols-[14rem_minmax(auto,_48rem)_14rem]', 'page-no-toc:xl:grid-cols-[14rem_minmax(auto,_48rem)_14rem]',
'[body:has(.site-full-width,.page-no-toc)_&]:lg:grid-cols-[minmax(auto,_90rem)_auto]', '[body:has(.site-width-wide,.page-no-toc)_&]:lg:grid-cols-[minmax(auto,_90rem)_auto]',
'[body:has(.site-full-width,.page-no-toc)_&]:xl:grid-cols-[14rem_minmax(auto,_90rem)_14rem]' '[body:has(.site-width-wide,.page-no-toc)_&]:xl:grid-cols-[14rem_minmax(auto,_90rem)_14rem]'
)} )}
> >
{ {
@@ -106,7 +106,7 @@ export function Footer(props: { context: GitBookSiteContext }) {
'col-span-2 page-has-toc:lg:col-span-1 page-has-toc:lg:col-start-2 page-no-toc:xl:col-span-1 page-no-toc:xl:col-start-2' 'col-span-2 page-has-toc:lg:col-span-1 page-has-toc:lg:col-start-2 page-no-toc:xl:col-span-1 page-no-toc:xl:col-start-2'
)} )}
> >
<div className="mx-auto flex max-w-3xl site-full-width:max-w-screen-2xl flex-col gap-10 sm:flex-row sm:gap-6"> <div className="mx-auto flex max-w-3xl site-width-wide:max-w-screen-2xl flex-col gap-10 sm:flex-row sm:gap-6">
{partition(customization.footer.groups, FOOTER_COLUMNS).map( {partition(customization.footer.groups, FOOTER_COLUMNS).map(
(column, columnIndex) => ( (column, columnIndex) => (
<div <div
@@ -55,7 +55,7 @@ export function PageAside(props: {
'chat-open:xl:max-3xl:max-w-0', 'chat-open:xl:max-3xl:max-w-0',
'chat-open:xl:max-3xl:ml-0', 'chat-open:xl:max-3xl:ml-0',
'motion-safe:transition-all motion-safe:duration-300', 'motion-safe:xl:transition-all motion-safe:xl:duration-300',
'motion-safe:[transition-behavior:allow-discrete]', 'motion-safe:[transition-behavior:allow-discrete]',
'flex-col', 'flex-col',
@@ -95,8 +95,9 @@ export function PageAside(props: {
'page-api-block:xl:max-2xl:dark:hover:shadow-tint-1/1', 'page-api-block:xl:max-2xl:dark:hover:shadow-tint-1/1',
'page-api-block:xl:max-2xl:rounded-md', 'page-api-block:xl:max-2xl:rounded-md',
'page-api-block:xl:max-2xl:h-auto', 'page-api-block:xl:max-2xl:h-auto',
'page-api-block:xl:max-2xl:my-4', 'page-api-block:xl:max-2xl:my-9',
'page-api-block:p-2' 'page-api-block:px-2',
'page-api-block:py-1.5'
)} )}
> >
{page.layout.outline ? ( {page.layout.outline ? (
@@ -40,8 +40,8 @@ export function PageBody(props: {
LINK_PREVIEW_MAX_COUNT LINK_PREVIEW_MAX_COUNT
) )
: false; : false;
const pageFullWidth = page.id === 'wtthNFMqmEQmnt5LKR0q'; const pageWidthWide = page.layout.width === 'wide';
const asFullWidth = pageFullWidth || contentFullWidth; const siteWidthWide = pageWidthWide || contentFullWidth;
const language = getSpaceLanguage(customization); const language = getSpaceLanguage(customization);
const updatedAt = page.updatedAt ?? page.createdAt; const updatedAt = page.updatedAt ?? page.createdAt;
@@ -53,12 +53,12 @@ export function PageBody(props: {
'mx-auto max-w-screen-2xl py-8', 'mx-auto max-w-screen-2xl py-8',
// Allow words to break if they are too long. // Allow words to break if they are too long.
'break-anywhere', 'break-anywhere',
pageFullWidth ? 'page-full-width 2xl:px-8' : 'page-default-width', pageWidthWide ? 'page-width-wide 2xl:px-8' : 'page-width-default',
asFullWidth ? 'site-full-width' : 'site-default-width', siteWidthWide ? 'site-width-wide' : 'site-width-default',
page.layout.tableOfContents ? 'page-has-toc' : 'page-no-toc' page.layout.tableOfContents ? 'page-has-toc' : 'page-no-toc'
)} )}
> >
<PreservePageLayout asFullWidth={asFullWidth} /> <PreservePageLayout siteWidthWide={siteWidthWide} />
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? ( {page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
<PageCover as="hero" page={page} cover={page.cover} context={context} /> <PageCover as="hero" page={page} cover={page.cover} context={context} />
) : null} ) : null}
@@ -93,19 +93,28 @@ export function PageBody(props: {
<PageFooterNavigation context={context} page={page} /> <PageFooterNavigation context={context} page={page} />
) : null} ) : null}
<div className="mx-auto mt-6 page-api-block:ml-0 flex max-w-3xl page-full-width:max-w-screen-2xl flex-row flex-wrap items-center gap-4 text-tint contrast-more:text-tint-strong"> {
{updatedAt ? ( // TODO: after 25/07/2025, we can chage it to a true check as the cache will be updated
<p className="mr-auto text-sm"> page.layout.metadata !== false ? (
{t(language, 'page_last_modified', <DateRelative value={updatedAt} />)} <div className="mx-auto mt-6 page-api-block:ml-0 flex max-w-3xl page-full-width:max-w-screen-2xl flex-row flex-wrap items-center gap-4 text-tint contrast-more:text-tint-strong">
</p> {updatedAt ? (
) : null} <p className="mr-auto text-sm ">
{withPageFeedback ? ( {t(
<PageFeedbackForm language,
className={page.layout.outline ? 'xl:hidden' : ''} 'page_last_modified',
pageId={page.id} <DateRelative value={updatedAt} />
/> )}
) : null} </p>
</div> ) : null}
{withPageFeedback ? (
<PageFeedbackForm
className={page.layout.outline ? 'xl:hidden' : ''}
pageId={page.id}
/>
) : null}
</div>
) : null
}
</main> </main>
<TrackPageViewEvent /> <TrackPageViewEvent />
@@ -71,11 +71,13 @@ export async function PageBodyBlankslate(props: {
className={tcls( className={tcls(
'grid', 'grid',
'max-w-3xl', 'max-w-3xl',
'page-width-wide:max-w-screen-2xl',
'w-full', 'w-full',
'mx-auto', 'mx-auto',
'gap-4', 'gap-4',
'grid-cols-1', 'grid-cols-1',
'sm:grid-cols-2' 'sm:grid-cols-2',
'page-width-wide:md:grid-cols-3'
)} )}
> >
{pageElements} {pageElements}
@@ -95,7 +95,7 @@ export async function PageCover(props: {
: [ : [
'sm:mx-auto', 'sm:mx-auto',
'max-w-3xl ', 'max-w-3xl ',
'page-full-width:max-w-screen-2xl', 'page-width-wide:max-w-screen-2xl',
'sm:rounded-md', 'sm:rounded-md',
'mb-8', 'mb-8',
] ]
@@ -34,7 +34,7 @@ export async function PageFooterNavigation(props: {
'mt-6', 'mt-6',
'gap-2', 'gap-2',
'max-w-3xl', 'max-w-3xl',
'page-full-width:max-w-screen-2xl', 'page-width-wide:max-w-screen-2xl',
'mx-auto', 'mx-auto',
'text-tint' 'text-tint'
)} )}
@@ -3,7 +3,7 @@ import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
import type { GitBookSiteContext } from '@/lib/context'; import type { GitBookSiteContext } from '@/lib/context';
import type { AncestorRevisionPage } from '@/lib/pages'; import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { type RevisionPageDocument, SiteVisibility } from '@gitbook/api'; import type { RevisionPageDocument } from '@gitbook/api';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
import { PageIcon } from '../PageIcon'; import { PageIcon } from '../PageIcon';
import { StyledLink } from '../primitives'; import { StyledLink } from '../primitives';
@@ -22,11 +22,17 @@ export async function PageHeader(props: {
const withAIChat = isAIChatEnabled(context); const withAIChat = isAIChatEnabled(context);
const pageActions = context.customization.pageActions ?? {
// TODO: After 25/07/2025, we can remove this default values as the cache will be updated
markdown: true,
externalAI: true,
};
return ( return (
<header <header
className={tcls( className={tcls(
'max-w-3xl', 'max-w-3xl',
'page-full-width:max-w-screen-2xl', 'page-width-wide:max-w-screen-2xl',
'mx-auto', 'mx-auto',
'mb-6', 'mb-6',
'space-y-3', 'space-y-3',
@@ -34,10 +40,12 @@ export async function PageHeader(props: {
'page-api-block:max-w-full' 'page-api-block:max-w-full'
)} )}
> >
{page.layout.tableOfContents ? ( {page.layout.tableOfContents &&
// Show page actions if *any* of the actions are enabled
(withAIChat || pageActions.markdown || pageActions.externalAI) ? (
<div <div
className={tcls( className={tcls(
'float-right mb-2 ml-4', 'float-right mb-2 ml-4 page-api-block:xl:max-2xl:mr-[14.5rem]',
ancestors.length > 0 ? '-mt-2' : 'xs:mt-2' ancestors.length > 0 ? '-mt-2' : 'xs:mt-2'
)} )}
> >
@@ -45,7 +53,7 @@ export async function PageHeader(props: {
markdownPageUrl={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`} markdownPageUrl={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`}
withAIChat={withAIChat} withAIChat={withAIChat}
trademark={context.customization.trademark.enabled} trademark={context.customization.trademark.enabled}
withLLMActions={context.site.visibility === SiteVisibility.Public} actions={pageActions}
/> />
</div> </div>
) : null} ) : null}
@@ -6,14 +6,14 @@ import * as React from 'react';
* This approach is needed as page layout (full width block) is done using CSS (`body:has(.full-width)`), * This approach is needed as page layout (full width block) is done using CSS (`body:has(.full-width)`),
* which becomes false while transitioning between the 2 page states: * which becomes false while transitioning between the 2 page states:
* *
* 1. Page 1 with full width block: `body:has(.site-full-width)` is true * 1. Page 1 with full width block: `body:has(.site-width-wide)` is true
* 2. Loading skeleton while transitioning to page 2: `body:has(.site-full-width)` is false * 2. Loading skeleton while transitioning to page 2: `body:has(.site-width-wide)` is false
* 3. Page 2 with full width block: `body:has(.site-full-width)` is true * 3. Page 2 with full width block: `body:has(.site-width-wide)` is true
* *
* This component ensures that the layout is preserved while transitioning between the 2 page states (in step 2). * This component ensures that the layout is preserved while transitioning between the 2 page states (in step 2).
*/ */
export function PreservePageLayout(props: { asFullWidth: boolean }) { export function PreservePageLayout(props: { siteWidthWide: boolean }) {
const { asFullWidth } = props; const { siteWidthWide } = props;
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
// We use the header as it's an element preserved between page transitions // We use the header as it's an element preserved between page transitions
@@ -23,12 +23,12 @@ export function PreservePageLayout(props: { asFullWidth: boolean }) {
return; return;
} }
if (asFullWidth) { if (siteWidthWide) {
header.classList.add('site-full-width'); header.classList.add('site-width-wide');
} else { } else {
header.classList.remove('site-full-width'); header.classList.remove('site-width-wide');
} }
}, [asFullWidth]); }, [siteWidthWide]);
return null; return null;
} }
@@ -54,10 +54,9 @@
} }
.flip-heading-hash { .flip-heading-hash {
@apply [&:is(h1,h2,h3,h4)]:content-start; @apply [&:is(h1,h2,h3,h4)]:flex-row-reverse;
@apply [&:is(h1,h2,h3,h4)]:[grid-auto-columns:auto_1fr]; @apply [&:is(h1,h2,h3,h4)]:[justify-content:start];
@apply [&:is(h1,h2,h3,h4)>div:first-child]:[grid-area:1/2]; @apply [&:is(h1,h2,h3,h4)>div:first-child]:ml-2;
@apply [&:is(h1,h2,h3,h4)>div:first-child]:ml-1;
} }
} }
@@ -10,6 +10,7 @@ import { t } from '@/intl/translate';
import type { TranslationLanguage } from '@/intl/translations'; import type { TranslationLanguage } from '@/intl/translations';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { AIResponseFeedback } from '../AIChat';
import { useTrackEvent } from '../Insights'; import { useTrackEvent } from '../Insights';
import { Link } from '../primitives'; import { Link } from '../primitives';
import { useSearchAskContext } from './SearchAskContext'; import { useSearchAskContext } from './SearchAskContext';
@@ -97,7 +98,11 @@ export function SearchAskAnswer(props: { query: string }) {
<div className="flex min-h-full p-4"> <div className="flex min-h-full p-4">
{askState?.type === 'answer' ? ( {askState?.type === 'answer' ? (
<React.Suspense fallback={loading}> <React.Suspense fallback={loading}>
<TransitionAnswerBody answer={askState.answer} placeholder={loading} /> <TransitionAnswerBody
answer={askState.answer}
placeholder={loading}
query={query}
/>
</React.Suspense> </React.Suspense>
) : null} ) : null}
{askState?.type === 'error' ? ( {askState?.type === 'error' ? (
@@ -114,8 +119,12 @@ export function SearchAskAnswer(props: { query: string }) {
* Since the answer can be an async component that could suspend rendering, * Since the answer can be an async component that could suspend rendering,
* we need to wrap it in a transition to avoid flickering. * we need to wrap it in a transition to avoid flickering.
*/ */
function TransitionAnswerBody(props: { answer: AskAnswerResult; placeholder: React.ReactNode }) { function TransitionAnswerBody(props: {
const { answer, placeholder } = props; query: string;
answer: AskAnswerResult;
placeholder: React.ReactNode;
}) {
const { query, answer, placeholder } = props;
const [display, setDisplay] = React.useState<AskAnswerResult | null>(null); const [display, setDisplay] = React.useState<AskAnswerResult | null>(null);
const [_isPending, startTransition] = React.useTransition(); const [_isPending, startTransition] = React.useTransition();
@@ -127,21 +136,25 @@ function TransitionAnswerBody(props: { answer: AskAnswerResult; placeholder: Rea
return display ? ( return display ? (
<div className={tcls('w-full')}> <div className={tcls('w-full')}>
<AnswerBody answer={display} /> <AnswerBody query={query} answer={display} />
</div> </div>
) : ( ) : (
<>{placeholder}</> <>{placeholder}</>
); );
} }
function AnswerBody(props: { answer: AskAnswerResult }) { function AnswerBody(props: { query: string; answer: AskAnswerResult }) {
const { answer } = props; const { query, answer } = props;
const language = useLanguage(); const language = useLanguage();
return ( return (
<> <>
<div data-testid="search-ask-answer" className="text-tint-strong"> <div data-testid="search-ask-answer" className="text-tint-strong">
{answer.body ?? t(language, 'search_ask_no_answer')} {answer.body ?? t(language, 'search_ask_no_answer')}
{answer.sources.length > 0 ? (
// @TODO: Add responseId once search uses new AI endpoint
<AIResponseFeedback query={query} className="-ml-1 mt-2" responseId="" />
) : null}
{answer.followupQuestions.length > 0 ? ( {answer.followupQuestions.length > 0 ? (
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} /> <AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />
) : null} ) : null}
@@ -162,7 +175,7 @@ function AnswerFollowupQuestions(props: { followupQuestions: string[] }) {
const getSearchLinkProps = useSearchLink(); const getSearchLinkProps = useSearchLink();
return ( return (
<div className={tcls('flex', 'flex-col', 'flex-wrap', 'mt-4', 'sm:mt-6')}> <div className={tcls('flex', 'flex-col', 'flex-wrap', 'mt-4')}>
{followupQuestions.map((question) => ( {followupQuestions.map((question) => (
<Link <Link
key={question} key={question}
@@ -27,8 +27,6 @@ interface SearchContainerProps {
*/ */
export function SearchContainer(props: SearchContainerProps) { export function SearchContainer(props: SearchContainerProps) {
const { spaceTitle, isMultiVariants, aiMode, className } = props; const { spaceTitle, isMultiVariants, aiMode, className } = props;
const withAI =
aiMode === CustomizationAIMode.Search || aiMode === CustomizationAIMode.Assistant;
const [state, setSearchState] = useSearch(); const [state, setSearchState] = useSearch();
const searchAsk = useSearchAskState(); const searchAsk = useSearchAskState();
@@ -122,7 +120,7 @@ export function SearchContainer(props: SearchContainerProps) {
<Popover <Popover
content={ content={
// Only show content if there's a query or Ask is enabled // Only show content if there's a query or Ask is enabled
(state?.query || withAI) && open ? ( (state?.query || aiMode !== CustomizationAIMode.None) && open ? (
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
{isMultiVariants && !state?.ask ? ( {isMultiVariants && !state?.ask ? (
<SearchScopeToggle spaceTitle={spaceTitle} /> <SearchScopeToggle spaceTitle={spaceTitle} />
@@ -174,7 +172,7 @@ export function SearchContainer(props: SearchContainerProps) {
onFocus={onOpen} onFocus={onOpen}
onChange={onChange} onChange={onChange}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
withAI={withAI} aiMode={aiMode}
isOpen={open} isOpen={open}
className={className} className={className}
/> />
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react';
import { tString, useLanguage } from '@/intl/client'; import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { CustomizationAIMode } from '@gitbook/api';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
import { Button, variantClasses } from '../primitives'; import { Button, variantClasses } from '../primitives';
import { useClassnames } from '../primitives/StyleProvider'; import { useClassnames } from '../primitives/StyleProvider';
@@ -13,7 +14,7 @@ interface SearchInputProps {
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void; onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onFocus: () => void; onFocus: () => void;
value: string; value: string;
withAI?: boolean; aiMode: CustomizationAIMode;
isOpen: boolean; isOpen: boolean;
className?: string; className?: string;
} }
@@ -26,7 +27,7 @@ const sizeClasses = ['text-sm', 'px-3.5', 'py-1.5', 'md:circular-corners:px-4'];
*/ */
export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>( export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
function SearchInput(props, ref) { function SearchInput(props, ref) {
const { onChange, onKeyDown, onFocus, value, withAI = false, isOpen, className } = props; const { onChange, onKeyDown, onFocus, value, aiMode, isOpen, className } = props;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const language = useLanguage(); const language = useLanguage();
@@ -59,7 +60,7 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
sizeClasses, sizeClasses,
// Additional custom styles // Additional custom styles
'has-[input:focus]:-translate-y-px h-9 grow cursor-pointer px-2.5 has-[input:focus]:bg-tint-base depth-subtle:has-[input:focus]:shadow-lg depth-subtle:has-[input:focus]:shadow-primary-subtle has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-primary-hover md:cursor-text', 'has-[input:focus]:-translate-y-px h-9 grow cursor-pointer px-2.5 has-[input:focus]:bg-tint-base depth-subtle:has-[input:focus]:shadow-lg depth-subtle:has-[input:focus]:shadow-primary-subtle has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-primary-hover md:cursor-text',
'theme-bold:has-[input:focus-visible]:border-header-link/6 theme-bold:has-[input:focus-visible]:bg-header-link/3', 'theme-bold:border-header-link/3 theme-bold:has-[input:focus-visible]:border-header-link/5 theme-bold:has-[input:focus-visible]:bg-header-link/3 theme-bold:has-[input:focus-visible]:ring-header-link/5',
'theme-bold:before:absolute theme-bold:before:inset-0 theme-bold:before:bg-header-background/7 theme-bold:before:backdrop-blur-xl ', // Special overlay to make the transparent colors of theme-bold visible. 'theme-bold:before:absolute theme-bold:before:inset-0 theme-bold:before:bg-header-background/7 theme-bold:before:backdrop-blur-xl ', // Special overlay to make the transparent colors of theme-bold visible.
'relative z-30 shrink grow justify-start max-md:absolute max-md:right-0', 'relative z-30 shrink grow justify-start max-md:absolute max-md:right-0',
isOpen ? 'max-md:w-56' : 'max-md:w-[38px]', isOpen ? 'max-md:w-56' : 'max-md:w-[38px]',
@@ -73,7 +74,7 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
size="medium" size="medium"
iconOnly iconOnly
icon="circle-xmark" icon="circle-xmark"
className="-ml-1.5 -mr-1 animate-scaleIn px-1.5 theme-bold:text-header-link theme-bold:hover:bg-header-link/3" className="-ml-1.5 -mr-1 animate-scaleIn px-1.5"
onClick={() => { onClick={() => {
onChange(''); onChange('');
inputRef.current?.focus(); inputRef.current?.focus();
@@ -89,15 +90,17 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
value={value} value={value}
placeholder={`${tString(language, withAI ? 'search_or_ask' : 'search')}...`} // We only show "search or ask" if the search input actually handles both search and ask.
placeholder={`${tString(language, aiMode === CustomizationAIMode.Search ? 'search_or_ask' : 'search')}...`}
maxLength={512} maxLength={512}
size={10}
className={tcls( className={tcls(
'peer z-10 min-w-0 grow bg-transparent py-0.5 text-tint-strong theme-bold:text-header-link outline-none transition-[width] duration-300 contain-paint placeholder:text-tint theme-bold:placeholder:text-current theme-bold:placeholder:opacity-7', 'peer z-10 min-w-0 grow bg-transparent py-0.5 text-tint-strong theme-bold:text-header-link outline-none transition-[width] duration-300 contain-paint placeholder:text-tint theme-bold:placeholder:text-current theme-bold:placeholder:opacity-7',
isOpen ? '' : 'max-md:opacity-0' isOpen ? '' : 'max-md:opacity-0'
)} )}
ref={inputRef} ref={inputRef}
/> />
<Shortcut /> {!isOpen ? <Shortcut /> : null}
</div> </div>
</div> </div>
); );
@@ -124,7 +127,7 @@ function Shortcut() {
<div <div
aria-busy={operatingSystem === null ? 'true' : undefined} aria-busy={operatingSystem === null ? 'true' : undefined}
className={tcls( className={tcls(
`shortcut -mr-1 hidden justify-end gap-0.5 whitespace-nowrap text-xs [font-feature-settings:"calt",_"case"] contrast-more:text-tint-strong md:flex`, `shortcut -mr-1 relative z-10 hidden justify-end gap-0.5 whitespace-nowrap text-xs [font-feature-settings:"calt",_"case"] after:absolute after:right-full after:z-20 after:h-full after:w-8 after:bg-gradient-to-r after:from-transparent after:to-tint-base theme-bold:after:to-transparent after:content-[''] contrast-more:text-tint-strong md:flex`,
operatingSystem operatingSystem
? 'motion-safe:animate-fadeIn motion-reduce:opacity-11' ? 'motion-safe:animate-fadeIn motion-reduce:opacity-11'
: 'opacity-0' : 'opacity-0'
@@ -3,15 +3,19 @@
import type { CustomizationThemeMode, SiteExternalLinksTarget } from '@gitbook/api'; import type { CustomizationThemeMode, SiteExternalLinksTarget } from '@gitbook/api';
import { ThemeProvider } from 'next-themes'; import { ThemeProvider } from 'next-themes';
import type React from 'react'; import type React from 'react';
import { useClearRouterCache } from '../hooks/useClearRouterCache';
import { LinkSettingsContext } from '../primitives'; import { LinkSettingsContext } from '../primitives';
export function ClientContexts(props: { export function ClientContexts(props: {
nonce?: string; nonce?: string;
forcedTheme: CustomizationThemeMode | undefined; forcedTheme: CustomizationThemeMode | undefined;
externalLinksTarget: SiteExternalLinksTarget; externalLinksTarget: SiteExternalLinksTarget;
contextId: string | undefined;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { children, forcedTheme, externalLinksTarget } = props; const { children, forcedTheme, externalLinksTarget, contextId } = props;
useClearRouterCache(contextId);
/** /**
* A bug in ThemeProvider is causing the nonce to be included incorrectly * A bug in ThemeProvider is causing the nonce to be included incorrectly
@@ -9,12 +9,11 @@ import { AdminToolbar } from '@/components/AdminToolbar';
import { CookiesToast } from '@/components/Cookies'; import { CookiesToast } from '@/components/Cookies';
import { LoadIntegrations } from '@/components/Integrations'; import { LoadIntegrations } from '@/components/Integrations';
import { SpaceLayout } from '@/components/SpaceLayout'; import { SpaceLayout } from '@/components/SpaceLayout';
import { buildVersion } from '@/lib/build';
import { isSiteIndexable } from '@/lib/seo';
import type { VisitorAuthClaims } from '@/lib/adaptive'; import type { VisitorAuthClaims } from '@/lib/adaptive';
import { buildVersion } from '@/lib/build';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL, GITBOOK_ICONS_URL } from '@/lib/env'; import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL, GITBOOK_ICONS_URL } from '@/lib/env';
import { getResizedImageURL } from '@/lib/images'; import { getResizedImageURL } from '@/lib/images';
import { isSiteIndexable } from '@/lib/seo';
import { ClientContexts } from './ClientContexts'; import { ClientContexts } from './ClientContexts';
import { RocketLoaderDetector } from './RocketLoaderDetector'; import { RocketLoaderDetector } from './RocketLoaderDetector';
@@ -50,6 +49,7 @@ export async function SiteLayout(props: {
<NuqsAdapter> <NuqsAdapter>
<ClientContexts <ClientContexts
nonce={nonce} nonce={nonce}
contextId={context.contextId}
forcedTheme={ forcedTheme={
forcedTheme ?? forcedTheme ??
(customization.themes.toggeable ? undefined : customization.themes.default) (customization.themes.toggeable ? undefined : customization.themes.default)
@@ -19,7 +19,7 @@ export function SitePageSkeleton() {
'lg:items-start' 'lg:items-start'
)} )}
> >
<div className={tcls('flex-1', 'max-w-3xl', 'mx-auto', 'site-full-width:mx-0')}> <div className={tcls('flex-1', 'max-w-3xl', 'mx-auto', 'site-width-wide:mx-0')}>
<SkeletonHeading style={tcls('mb-8')} /> <SkeletonHeading style={tcls('mb-8')} />
<SkeletonParagraph style={tcls('mb-4')} /> <SkeletonParagraph style={tcls('mb-4')} />
</div> </div>
@@ -97,7 +97,7 @@ export function SpaceLayout(props: {
'flex-col', 'flex-col',
'lg:flex-row', 'lg:flex-row',
CONTAINER_STYLE, CONTAINER_STYLE,
'site-full-width:max-w-full', 'site-width-wide:max-w-full',
// Ensure the footer is display below the viewport even if the content is not enough // Ensure the footer is display below the viewport even if the content is not enough
withFooter && 'min-h-[calc(100vh-64px)]', withFooter && 'min-h-[calc(100vh-64px)]',
@@ -40,6 +40,10 @@ export function Trademark(props: {
'[html.sidebar-filled.theme-muted_&]:bg-tint-base', '[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base', '[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'rounded-lg',
'straight-corners:rounded-none',
'circular-corners:rounded-2xl',
'before:hidden', 'before:hidden',
'lg:before:block', 'lg:before:block',
'before:content-[""]', 'before:content-[""]',
@@ -0,0 +1,30 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
// We cannot use a ref here because the contextId gets reset on navigation
// Probably because of this bug https://github.com/vercel/next.js/issues/67542
let previousContextId: string | undefined;
/**
* A custom hook that clears the router cache on contextId change.
* This is useful for ensuring that the router does not cache stale data for adaptive content.
*/
export function useClearRouterCache(contextId: string | undefined) {
const router = useRouter();
useEffect(() => {
if (previousContextId === undefined) {
// On the first run, we set the previousContextId to the current contextId
previousContextId = contextId;
return; // Skip the first run to avoid unnecessary reload
}
// Initially, previousContextId will be undefined, so we only clear the cache
// if contextId has changed from a defined value to a new value.
// This prevents unnecessary cache clearing on the first render.
if (contextId !== previousContextId && previousContextId !== undefined) {
previousContextId = contextId;
// Trigger a full reload to clear the in-memory cache
router.refresh(); // This will clear the cache and re-fetch the data
}
}, [contextId, router]);
}
@@ -14,7 +14,6 @@ export const CONTAINER_STYLE: ClassValue = [
'md:px-8', 'md:px-8',
'max-w-screen-2xl', 'max-w-screen-2xl',
'mx-auto', 'mx-auto',
// 'site-full-width:max-w-full',
]; ];
/** /**
@@ -31,6 +31,8 @@ export const variantClasses = {
'hover:text-contrast-primary-solid-hover', 'hover:text-contrast-primary-solid-hover',
'border-0', 'border-0',
'contrast-more:border-1', 'contrast-more:border-1',
'disabled:bg-tint',
'disabled:text-tint/8',
], ],
blank: [ blank: [
'bg-transparent', 'bg-transparent',
@@ -42,6 +44,8 @@ export const variantClasses = {
'hover:scale-1', 'hover:scale-1',
'contrast-more:bg-tint-subtle', 'contrast-more:bg-tint-subtle',
'depth-subtle:hover:translate-y-0', 'depth-subtle:hover:translate-y-0',
'disabled:text-tint/8',
'disabled:bg-transparent',
], ],
secondary: [ secondary: [
'bg-tint', 'bg-tint',
@@ -51,6 +55,8 @@ export const variantClasses = {
'depth-flat:hover:bg-tint-hover', 'depth-flat:hover:bg-tint-hover',
'hover:text-primary', 'hover:text-primary',
'contrast-more:bg-tint-subtle', 'contrast-more:bg-tint-subtle',
'disabled:bg-transparent',
'disabled:text-tint/8',
], ],
header: [ header: [
'bg-tint-base text-tint', 'bg-tint-base text-tint',
@@ -59,23 +65,23 @@ export const variantClasses = {
'theme-bold:bg-header-link/2', 'theme-bold:bg-header-link/2',
'theme-bold:text-header-link', 'theme-bold:text-header-link',
'theme-bold:!shadow-none', 'theme-bold:!shadow-none',
'theme-bold:ring-header-link/4', 'theme-bold:border-header-link/4',
'theme-bold:hover:bg-header-link/3', 'theme-bold:hover:bg-header-link/3',
'theme-bold:hover:text-header-link', 'theme-bold:hover:text-header-link',
'theme-bold:hover:shadow-none', 'theme-bold:hover:shadow-none',
'theme-bold:hover:ring-header-link/5', 'theme-bold:hover:border-header-link/5',
'theme-bold:contrast-more:bg-header-background', 'theme-bold:contrast-more:bg-header-background',
'theme-bold:contrast-more:text-header-link', 'theme-bold:contrast-more:text-header-link',
'theme-bold:contrast-more:ring-header-link', 'theme-bold:contrast-more:border-header-link',
'theme-bold:contrast-more:hover:ring-header-link', 'theme-bold:contrast-more:hover:border-header-link',
], ],
}; };
const activeClasses = { const activeClasses = {
primary: 'bg-primary-solid-hover', primary: 'bg-primary-solid-hover',
blank: 'bg-primary-active text-primary-strong font-medium hover:text-primary-strong hover:bg-primary-active', 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',
secondary: 'bg-tint-active', secondary: 'bg-tint-active',
header: 'bg-header-link/3', header: 'bg-header-link/3',
}; };
@@ -99,13 +105,14 @@ export const Button = React.forwardRef<
children, children,
active, active,
trailing, trailing,
disabled,
...rest ...rest
}, },
ref ref
) => { ) => {
const sizes = { const sizes = {
default: ['text-base', 'font-semibold', 'px-5', 'py-2', 'circular-corners:px-6'], default: ['text-base', 'font-semibold', 'px-5', 'py-2'],
medium: ['text-sm', 'px-3.5', 'py-1.5', 'circular-corners:px-4'], medium: ['text-sm', iconOnly ? 'px-2' : 'px-3.5', 'py-1.5'],
small: ['text-xs', 'py-2', iconOnly ? 'px-2' : 'px-3'], small: ['text-xs', 'py-2', iconOnly ? 'px-2' : 'px-3'],
xsmall: ['text-xs', 'py-1', iconOnly ? 'px-1.5' : 'px-2'], xsmall: ['text-xs', 'py-1', iconOnly ? 'px-1.5' : 'px-2'],
}; };
@@ -142,6 +149,7 @@ export const Button = React.forwardRef<
classNames={['ButtonStyles']} classNames={['ButtonStyles']}
insights={insights} insights={insights}
aria-label={label?.toString()} aria-label={label?.toString()}
aria-pressed={active === undefined ? undefined : active}
target={target} target={target}
{...rest} {...rest}
> >
@@ -156,12 +164,38 @@ export const Button = React.forwardRef<
type="button" type="button"
className={tcls(buttonOnlyClassNames, domClassName)} className={tcls(buttonOnlyClassNames, domClassName)}
aria-label={label?.toString()} aria-label={label?.toString()}
aria-pressed={active === undefined ? undefined : active}
disabled={disabled}
{...rest} {...rest}
> >
{content} {content}
</button> </button>
); );
return iconOnly && label ? <Tooltip label={label}>{button}</Tooltip> : button; return iconOnly && label ? (
<Tooltip
rootProps={{ open: disabled === true ? false : undefined }}
label={label}
triggerProps={{ disabled }}
>
{button}
</Tooltip>
) : (
button
);
} }
); );
export const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonProps>(({ children }, ref) => {
return (
<div
ref={ref}
className={tcls(
'*:!transform-none *:!shadow-none flex h-fit items-stretch justify-start',
'[&>*:not(:first-child)]:border-l-0 [&>*:not(:first-child,:last-child)]:rounded-none [&>*:not(:only-child):first-child]:rounded-r-none [&>*:not(:only-child):last-child]:rounded-l-none'
)}
>
{children}
</div>
);
});
@@ -86,7 +86,7 @@ export function DropdownMenu(props: {
> >
<div <div
className={tcls( className={tcls(
'flex max-h-80 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto circular-corners:rounded-xl rounded-md straight-corners:rounded-none border border-tint bg-tint-base p-2 shadow-lg sm:min-w-52 sm:max-w-80', 'flex max-h-96 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto circular-corners:rounded-xl rounded-md straight-corners:rounded-none border border-tint bg-tint-base p-2 shadow-lg sm:min-w-52 sm:max-w-80',
className className
)} )}
> >
@@ -194,7 +194,7 @@ export function DropdownSubMenu(props: { children: React.ReactNode; label: React
collisionPadding={8} collisionPadding={8}
className="z-40 animate-present" className="z-40 animate-present"
> >
<div className="flex max-h-80 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto rounded-lg straight-corners:rounded-sm bg-tint-base p-2 shadow-lg ring-1 ring-tint-subtle sm:min-w-52 sm:max-w-80"> <div className="flex max-h-96 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto rounded-lg straight-corners:rounded-sm bg-tint-base p-2 shadow-lg ring-1 ring-tint-subtle sm:min-w-52 sm:max-w-80">
{children} {children}
</div> </div>
</RadixDropdownMenu.SubContent> </RadixDropdownMenu.SubContent>
@@ -203,6 +203,15 @@ export function DropdownSubMenu(props: { children: React.ReactNode; label: React
); );
} }
export function DropdownMenuSeparator(props: { className?: ClassValue }) {
const { className } = props;
return (
<RadixDropdownMenu.Separator
className={tcls('my-1 h-px w-full border-tint-subtle border-t', className)}
/>
);
}
/** /**
* Hook to close the dropdown menu. * Hook to close the dropdown menu.
*/ */
@@ -123,11 +123,15 @@ export const Link = React.forwardRef(function Link(
); );
} }
// Not sure why yet, but it seems necessary to force prefetch to true
// default behavior doesn't seem to properly use the client router cache.
const _prefetch = prefetch === null || prefetch === undefined ? true : prefetch;
return ( return (
<NextLink <NextLink
ref={ref} ref={ref}
href={href} href={href}
prefetch={prefetch} prefetch={_prefetch}
className={tcls(...forwardedClassNames, className)} className={tcls(...forwardedClassNames, className)}
{...domProps} {...domProps}
onClick={onClick} onClick={onClick}
@@ -1,5 +1,9 @@
'use client';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import * as RadixTooltip from '@radix-ui/react-tooltip'; import * as RadixTooltip from '@radix-ui/react-tooltip';
import { useState } from 'react';
import { useIsMobile } from '../hooks/useIsMobile';
export function Tooltip(props: { export function Tooltip(props: {
children: React.ReactNode; children: React.ReactNode;
@@ -22,10 +26,24 @@ export function Tooltip(props: {
className, className,
} = props; } = props;
const isMobile = useIsMobile();
const [open, setOpen] = useState(false);
return ( return (
<RadixTooltip.Provider delayDuration={300} {...providerProps}> <RadixTooltip.Provider delayDuration={300} {...providerProps}>
<RadixTooltip.Root {...rootProps}> <RadixTooltip.Root open={open} onOpenChange={(e) => setOpen(e)} {...rootProps}>
<RadixTooltip.Trigger asChild {...triggerProps}> <RadixTooltip.Trigger
asChild
onClick={(event) => {
event.preventDefault();
if (isMobile) {
setOpen(true);
}
setOpen(true);
}}
onPointerDown={(event) => event.preventDefault()}
{...triggerProps}
>
{children} {children}
</RadixTooltip.Trigger> </RadixTooltip.Trigger>
<RadixTooltip.Portal> <RadixTooltip.Portal>
@@ -35,6 +53,12 @@ export function Tooltip(props: {
'z-50 max-w-xs animate-scaleIn circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-12 px-2 py-1 text-contrast-tint-12 text-sm', 'z-50 max-w-xs animate-scaleIn circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-12 px-2 py-1 text-contrast-tint-12 text-sm',
className className
)} )}
onPointerDownOutside={(event) => {
event.preventDefault();
if (isMobile) {
setOpen(false);
}
}}
{...contentProps} {...contentProps}
> >
{label} {label}
@@ -8,7 +8,7 @@ export const ButtonStyles = [
'gap-2', 'gap-2',
'rounded-md', 'rounded-md',
'straight-corners:rounded-none', 'straight-corners:rounded-none',
'circular-corners:rounded-full', 'circular-corners:rounded-3xl',
'border', 'border',
'border-tint', 'border-tint',
@@ -33,12 +33,9 @@ export const ButtonStyles = [
'shrink-0', 'shrink-0',
'truncate', 'truncate',
'disabled:opacity-50',
'disabled:cursor-not-allowed', 'disabled:cursor-not-allowed',
'disabled:bg-tint', 'disabled:!translate-y-0',
'disabled:text-tint/8', 'disabled:!shadow-none',
'disabled:shadow-none',
'disabled:hover:scale-100',
] as ClassValue[]; ] as ClassValue[];
export const CardStyles = [ export const CardStyles = [
@@ -1,7 +1,5 @@
import type { GitBookSiteContext } from '@/lib/context'; import type { GitBookSiteContext } from '@/lib/context';
import { CustomizationAIMode } from '@gitbook/api'; import { CustomizationAIMode } from '@gitbook/api';
// TODO: remove aiSearch and optional chain once the cache has been fully updated (after 11/07/2025)
export const isAIChatEnabled = (context: GitBookSiteContext) => export const isAIChatEnabled = (context: GitBookSiteContext) =>
context.customization.ai?.mode === CustomizationAIMode.Assistant && context.customization.ai.mode === CustomizationAIMode.Assistant;
(context.site.id === 'site_p4Xo4' || context.site.id === 'site_JOVzv');
@@ -32,6 +32,8 @@ export const de = {
was_this_helpful_negative: 'Nein', was_this_helpful_negative: 'Nein',
was_this_helpful_thank_you: 'Danke!', was_this_helpful_thank_you: 'Danke!',
was_this_helpful_comment: 'Möchten Sie etwas hinzufügen?', was_this_helpful_comment: 'Möchten Sie etwas hinzufügen?',
was_this_helpful_positive_label: 'Hilfreich',
was_this_helpful_negative_label: 'Nicht hilfreich',
submit: 'Absenden', submit: 'Absenden',
annotation_button_label: 'Kommentar öffnen', annotation_button_label: 'Kommentar öffnen',
code_copied: 'Kopiert!', code_copied: 'Kopiert!',
@@ -86,6 +88,7 @@ export const de = {
ai_chat_context_disclaimer: 'KI-Antworten können Fehler enthalten.', ai_chat_context_disclaimer: 'KI-Antworten können Fehler enthalten.',
ai_chat_input_placeholder: 'Fragen, suchen oder Aktion ausführen...', ai_chat_input_placeholder: 'Fragen, suchen oder Aktion ausführen...',
send: 'Senden', send: 'Senden',
actions: 'Aktionen',
ai_chat_suggested_questions_title: 'Vorgeschlagene Fragen', ai_chat_suggested_questions_title: 'Vorgeschlagene Fragen',
ai_chat_suggested_questions_about_this_page: 'Worum geht es auf dieser Seite?', ai_chat_suggested_questions_about_this_page: 'Worum geht es auf dieser Seite?',
ai_chat_suggested_questions_read_next: 'Was sollte ich als nächstes lesen?', ai_chat_suggested_questions_read_next: 'Was sollte ich als nächstes lesen?',
@@ -32,6 +32,8 @@ export const en = {
was_this_helpful_negative: 'No', was_this_helpful_negative: 'No',
was_this_helpful_thank_you: 'Thank you!', was_this_helpful_thank_you: 'Thank you!',
was_this_helpful_comment: "Anything you'd like to add?", was_this_helpful_comment: "Anything you'd like to add?",
was_this_helpful_positive_label: 'Helpful',
was_this_helpful_negative_label: 'Not helpful',
submit: 'Submit', submit: 'Submit',
annotation_button_label: 'Open annotation', annotation_button_label: 'Open annotation',
code_copied: 'Copied!', code_copied: 'Copied!',
@@ -84,6 +86,7 @@ export const en = {
ai_chat_context_disclaimer: 'AI responses may contain mistakes.', ai_chat_context_disclaimer: 'AI responses may contain mistakes.',
ai_chat_input_placeholder: 'Ask, search, or take action...', ai_chat_input_placeholder: 'Ask, search, or take action...',
send: 'Send', send: 'Send',
actions: 'Actions',
ai_chat_suggested_questions_title: 'Suggested questions', ai_chat_suggested_questions_title: 'Suggested questions',
ai_chat_suggested_questions_about_this_page: 'What is this page about?', ai_chat_suggested_questions_about_this_page: 'What is this page about?',
ai_chat_suggested_questions_read_next: 'What should I read next?', ai_chat_suggested_questions_read_next: 'What should I read next?',
@@ -34,6 +34,8 @@ export const es: TranslationLanguage = {
was_this_helpful_negative: 'No', was_this_helpful_negative: 'No',
was_this_helpful_thank_you: '¡Gracias!', was_this_helpful_thank_you: '¡Gracias!',
was_this_helpful_comment: '¿Algo más que te gustaría añadir?', was_this_helpful_comment: '¿Algo más que te gustaría añadir?',
was_this_helpful_positive_label: 'Útil',
was_this_helpful_negative_label: 'No útil',
submit: 'Enviar', submit: 'Enviar',
annotation_button_label: 'Abrir anotación', annotation_button_label: 'Abrir anotación',
code_copied: '¡Copiado!', code_copied: '¡Copiado!',
@@ -88,6 +90,7 @@ export const es: TranslationLanguage = {
ai_chat_context_disclaimer: 'Las respuestas de IA pueden contener errores.', ai_chat_context_disclaimer: 'Las respuestas de IA pueden contener errores.',
ai_chat_input_placeholder: 'Pregunta, busca o realiza una acción...', ai_chat_input_placeholder: 'Pregunta, busca o realiza una acción...',
send: 'Enviar', send: 'Enviar',
actions: 'Acciones',
ai_chat_suggested_questions_title: 'Preguntas sugeridas', ai_chat_suggested_questions_title: 'Preguntas sugeridas',
ai_chat_suggested_questions_about_this_page: '¿De qué trata esta página?', ai_chat_suggested_questions_about_this_page: '¿De qué trata esta página?',
ai_chat_suggested_questions_read_next: '¿Qué debería leer después?', ai_chat_suggested_questions_read_next: '¿Qué debería leer después?',
@@ -34,6 +34,8 @@ export const fr: TranslationLanguage = {
was_this_helpful_negative: 'Non', was_this_helpful_negative: 'Non',
was_this_helpful_thank_you: 'Merci!', was_this_helpful_thank_you: 'Merci!',
was_this_helpful_comment: 'Quelque chose à ajouter?', was_this_helpful_comment: 'Quelque chose à ajouter?',
was_this_helpful_positive_label: 'Utile',
was_this_helpful_negative_label: 'Pas utile',
submit: 'Soumettre', submit: 'Soumettre',
annotation_button_label: "Ouvrir l'annotation", annotation_button_label: "Ouvrir l'annotation",
code_copied: 'Copié !', code_copied: 'Copié !',
@@ -86,6 +88,7 @@ export const fr: TranslationLanguage = {
ai_chat_context_disclaimer: "Les réponses de l'IA peuvent contenir des erreurs.", ai_chat_context_disclaimer: "Les réponses de l'IA peuvent contenir des erreurs.",
ai_chat_input_placeholder: 'Demander, rechercher ou effectuer une action...', ai_chat_input_placeholder: 'Demander, rechercher ou effectuer une action...',
send: 'Envoyer', send: 'Envoyer',
actions: 'Actions',
ai_chat_suggested_questions_title: 'Questions suggérées', ai_chat_suggested_questions_title: 'Questions suggérées',
ai_chat_suggested_questions_about_this_page: 'De quoi parle cette page ?', ai_chat_suggested_questions_about_this_page: 'De quoi parle cette page ?',
ai_chat_suggested_questions_read_next: 'Que devrais-je lire ensuite ?', ai_chat_suggested_questions_read_next: 'Que devrais-je lire ensuite ?',
@@ -34,6 +34,8 @@ export const ja: TranslationLanguage = {
was_this_helpful_negative: 'いいえ', was_this_helpful_negative: 'いいえ',
was_this_helpful_thank_you: 'ありがとうございます!', was_this_helpful_thank_you: 'ありがとうございます!',
was_this_helpful_comment: '何か追加したいことはありますか?', was_this_helpful_comment: '何か追加したいことはありますか?',
was_this_helpful_positive_label: '役立つ',
was_this_helpful_negative_label: '役立たない',
submit: '送信', submit: '送信',
annotation_button_label: '注釈を開く', annotation_button_label: '注釈を開く',
code_copied: 'コピーしました!', code_copied: 'コピーしました!',
@@ -86,6 +88,7 @@ export const ja: TranslationLanguage = {
ai_chat_context_disclaimer: 'AIの回答には誤りが含まれる場合があります。', ai_chat_context_disclaimer: 'AIの回答には誤りが含まれる場合があります。',
ai_chat_input_placeholder: '質問、検索、またはアクションを実行...', ai_chat_input_placeholder: '質問、検索、またはアクションを実行...',
send: '送信', send: '送信',
actions: 'アクション',
ai_chat_suggested_questions_title: 'おすすめの質問', ai_chat_suggested_questions_title: 'おすすめの質問',
ai_chat_suggested_questions_about_this_page: 'このページは何について説明していますか?', ai_chat_suggested_questions_about_this_page: 'このページは何について説明していますか?',
ai_chat_suggested_questions_read_next: '次に何を読むべきですか?', ai_chat_suggested_questions_read_next: '次に何を読むべきですか?',
@@ -34,6 +34,8 @@ export const nl: TranslationLanguage = {
was_this_helpful_negative: 'Nee', was_this_helpful_negative: 'Nee',
was_this_helpful_thank_you: 'Bedankt!', was_this_helpful_thank_you: 'Bedankt!',
was_this_helpful_comment: 'Nog iets toe te voegen?', was_this_helpful_comment: 'Nog iets toe te voegen?',
was_this_helpful_positive_label: 'Nuttig',
was_this_helpful_negative_label: 'Niet nuttig',
submit: 'Versturen', submit: 'Versturen',
annotation_button_label: 'Open annotatie', annotation_button_label: 'Open annotatie',
code_copied: 'Gekopieerd!', code_copied: 'Gekopieerd!',
@@ -86,6 +88,7 @@ export const nl: TranslationLanguage = {
ai_chat_context_disclaimer: 'AI-antwoorden kunnen fouten bevatten.', ai_chat_context_disclaimer: 'AI-antwoorden kunnen fouten bevatten.',
ai_chat_input_placeholder: 'Vraag, zoek of voer een actie uit...', ai_chat_input_placeholder: 'Vraag, zoek of voer een actie uit...',
send: 'Versturen', send: 'Versturen',
actions: 'Acties',
ai_chat_suggested_questions_title: 'Voorgestelde vragen', ai_chat_suggested_questions_title: 'Voorgestelde vragen',
ai_chat_suggested_questions_about_this_page: 'Waar gaat deze pagina over?', ai_chat_suggested_questions_about_this_page: 'Waar gaat deze pagina over?',
ai_chat_suggested_questions_read_next: 'Wat moet ik hierna lezen?', ai_chat_suggested_questions_read_next: 'Wat moet ik hierna lezen?',
@@ -34,6 +34,8 @@ export const no: TranslationLanguage = {
was_this_helpful_negative: 'Nei', was_this_helpful_negative: 'Nei',
was_this_helpful_thank_you: 'Takk!', was_this_helpful_thank_you: 'Takk!',
was_this_helpful_comment: 'Noe du vil legge til?', was_this_helpful_comment: 'Noe du vil legge til?',
was_this_helpful_positive_label: 'Nyttig',
was_this_helpful_negative_label: 'Ikke nyttig',
submit: 'Send inn', submit: 'Send inn',
annotation_button_label: 'Åpne merknad', annotation_button_label: 'Åpne merknad',
code_copied: 'Kopiert!', code_copied: 'Kopiert!',
@@ -86,6 +88,7 @@ export const no: TranslationLanguage = {
ai_chat_context_disclaimer: 'AI-svar kan inneholde feil.', ai_chat_context_disclaimer: 'AI-svar kan inneholde feil.',
ai_chat_input_placeholder: 'Spør, søk eller utfør en handling...', ai_chat_input_placeholder: 'Spør, søk eller utfør en handling...',
send: 'Send', send: 'Send',
actions: 'Handlinger',
ai_chat_suggested_questions_title: 'Foreslåtte spørsmål', ai_chat_suggested_questions_title: 'Foreslåtte spørsmål',
ai_chat_suggested_questions_about_this_page: 'Hva handler denne siden om?', ai_chat_suggested_questions_about_this_page: 'Hva handler denne siden om?',
ai_chat_suggested_questions_read_next: 'Hva bør jeg lese neste?', ai_chat_suggested_questions_read_next: 'Hva bør jeg lese neste?',
@@ -32,6 +32,8 @@ export const pt_br = {
was_this_helpful_negative: 'Não', was_this_helpful_negative: 'Não',
was_this_helpful_thank_you: 'Obrigado!', was_this_helpful_thank_you: 'Obrigado!',
was_this_helpful_comment: 'Gostaria de adicionar algo?', was_this_helpful_comment: 'Gostaria de adicionar algo?',
was_this_helpful_positive_label: 'Útil',
was_this_helpful_negative_label: 'Não útil',
submit: 'Enviar', submit: 'Enviar',
annotation_button_label: 'Abrir anotação', annotation_button_label: 'Abrir anotação',
code_copied: 'Copiado!', code_copied: 'Copiado!',
@@ -86,6 +88,7 @@ export const pt_br = {
ai_chat_context_disclaimer: 'Respostas de IA podem conter erros.', ai_chat_context_disclaimer: 'Respostas de IA podem conter erros.',
ai_chat_input_placeholder: 'Pergunte, pesquise ou execute uma ação...', ai_chat_input_placeholder: 'Pergunte, pesquise ou execute uma ação...',
send: 'Enviar', send: 'Enviar',
actions: 'Ações',
ai_chat_suggested_questions_title: 'Perguntas sugeridas', ai_chat_suggested_questions_title: 'Perguntas sugeridas',
ai_chat_suggested_questions_about_this_page: 'Sobre o que é esta página?', ai_chat_suggested_questions_about_this_page: 'Sobre o que é esta página?',
ai_chat_suggested_questions_read_next: 'O que devo ler em seguida?', ai_chat_suggested_questions_read_next: 'O que devo ler em seguida?',
@@ -33,6 +33,8 @@ export const zh: TranslationLanguage = {
was_this_helpful_negative: '不', was_this_helpful_negative: '不',
was_this_helpful_thank_you: '谢谢!', was_this_helpful_thank_you: '谢谢!',
was_this_helpful_comment: '您有什么想补充的吗?', was_this_helpful_comment: '您有什么想补充的吗?',
was_this_helpful_positive_label: '有帮助',
was_this_helpful_negative_label: '没有帮助',
submit: '提交', submit: '提交',
annotation_button_label: '打开批注', annotation_button_label: '打开批注',
code_copied: '已复制!', code_copied: '已复制!',
@@ -83,6 +85,7 @@ export const zh: TranslationLanguage = {
ai_chat_context_disclaimer: '人工智能的回答可能包含错误。', ai_chat_context_disclaimer: '人工智能的回答可能包含错误。',
ai_chat_input_placeholder: '询问、搜索或执行操作...', ai_chat_input_placeholder: '询问、搜索或执行操作...',
send: '发送', send: '发送',
actions: '操作',
ai_chat_suggested_questions_title: '建议的问题', ai_chat_suggested_questions_title: '建议的问题',
ai_chat_suggested_questions_about_this_page: '这个页面讲的是什么?', ai_chat_suggested_questions_about_this_page: '这个页面讲的是什么?',
ai_chat_suggested_questions_read_next: '我接下来应该读什么?', ai_chat_suggested_questions_read_next: '我接下来应该读什么?',
+7
View File
@@ -44,6 +44,7 @@ export type SiteURLData = Pick<
| 'siteSection' | 'siteSection'
| 'siteBasePath' | 'siteBasePath'
| 'basePath' | 'basePath'
| 'contextId'
> & { > & {
/** /**
* Identifier used for image resizing. * Identifier used for image resizing.
@@ -123,6 +124,9 @@ export type GitBookSiteContext = GitBookSpaceContext & {
/** Scripts to load for the site. */ /** Scripts to load for the site. */
scripts: SiteIntegrationScript[]; scripts: SiteIntegrationScript[];
/** Context ID used by adaptive content. It represents an unique identifier for the authentication context */
contextId?: string;
}; };
/** /**
@@ -200,6 +204,7 @@ export async function fetchSiteContextByURLLookup(
shareKey: data.shareKey, shareKey: data.shareKey,
changeRequest: data.changeRequest, changeRequest: data.changeRequest,
revision: data.revision, revision: data.revision,
contextId: data.contextId,
}); });
} }
@@ -217,6 +222,7 @@ export async function fetchSiteContextByIds(
shareKey: string | undefined; shareKey: string | undefined;
changeRequest: string | undefined; changeRequest: string | undefined;
revision: string | undefined; revision: string | undefined;
contextId?: string;
} }
): Promise<GitBookSiteContext> { ): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext; const { dataFetcher } = baseContext;
@@ -314,6 +320,7 @@ export async function fetchSiteContextByIds(
structure: siteStructure, structure: siteStructure,
sections, sections,
scripts, scripts,
contextId: ids.contextId,
}; };
} }
-7
View File
@@ -181,9 +181,6 @@ export function extractCacheControl(error: GitBookAPIError) {
*/ */
export function getExposableError(error: Error): DataFetcherErrorData { export function getExposableError(error: Error): DataFetcherErrorData {
if (error instanceof GitBookAPIError) { if (error instanceof GitBookAPIError) {
if (error.code >= 500) {
throw error;
}
const cache = extractCacheControl(error); const cache = extractCacheControl(error);
return { return {
@@ -194,10 +191,6 @@ export function getExposableError(error: Error): DataFetcherErrorData {
} }
if (error instanceof DataFetcherError) { if (error instanceof DataFetcherError) {
if (error.code >= 500) {
throw error;
}
return { return {
code: error.code, code: error.code,
message: error.message, message: error.message,
@@ -48,10 +48,15 @@ export async function resizeImage(
input: string, input: string,
options: CloudflareImageOptions & { options: CloudflareImageOptions & {
signal?: AbortSignal; signal?: AbortSignal;
/**
* Bypass the check to see if the image can be resized.
* This is useful for some format that are not supported by @next/og and need to be transformed
*/
bypassSkipCheck?: boolean;
} }
): Promise<Response> { ): Promise<Response> {
const action = checkIsSizableImageURL(input); const action = checkIsSizableImageURL(input);
if (action === SizableImageAction.Skip) { if (action === SizableImageAction.Skip && !options.bypassSkipCheck) {
throw new Error( throw new Error(
'Cannot resize this image, this function should have never been called on this url' 'Cannot resize this image, this function should have never been called on this url'
); );
+7 -3
View File
@@ -7,7 +7,8 @@ import {
import { DataFetcherError, noCacheFetchOptions } from '@/lib/data'; import { DataFetcherError, noCacheFetchOptions } from '@/lib/data';
import { resolveContentRef } from '@/lib/references'; import { resolveContentRef } from '@/lib/references';
import { unstable_cacheLife as cacheLife } from 'next/cache'; import { getCacheTag } from '@gitbook/cache-tags';
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import { assert } from 'ts-essentials'; import { assert } from 'ts-essentials';
import { enrichFilesystem } from './enrich'; import { enrichFilesystem } from './enrich';
import type { import type {
@@ -40,7 +41,7 @@ export async function fetchOpenAPIFilesystem(
assert(resolved.openAPIFilesystem); assert(resolved.openAPIFilesystem);
return resolved.openAPIFilesystem; return resolved.openAPIFilesystem;
} }
return fetchFilesystem(resolved.href); return fetchFilesystem(resolved.href, context.space.id);
})(); })();
if ('error' in filesystem) { if ('error' in filesystem) {
@@ -54,7 +55,8 @@ export async function fetchOpenAPIFilesystem(
} }
const fetchFilesystem = async ( const fetchFilesystem = async (
url: string url: string,
spaceId: string
): Promise< ): Promise<
| Filesystem | Filesystem
| { | {
@@ -66,6 +68,7 @@ const fetchFilesystem = async (
> => { > => {
'use cache'; 'use cache';
try { try {
cacheTag(getCacheTag({ tag: 'space', space: spaceId }));
return await fetchFilesystemUncached(url); return await fetchFilesystemUncached(url);
} catch (error) { } catch (error) {
// To avoid hammering the file with requests, we cache the error for around a minute. // To avoid hammering the file with requests, we cache the error for around a minute.
@@ -96,6 +99,7 @@ async function fetchFilesystemUncached(
// https://github.com/cloudflare/workerd/issues/1957 // https://github.com/cloudflare/workerd/issues/1957
const response = await fetch(new URL(url), { const response = await fetch(new URL(url), {
...noCacheFetchOptions, ...noCacheFetchOptions,
cache: 'no-store',
signal: options?.signal, signal: options?.signal,
}); });
+17 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'bun:test';
import type { RevisionPage } from '@gitbook/api'; import {
type RevisionPage,
RevisionPageLayoutOptionsCoverSize,
RevisionPageLayoutOptionsWidth,
} from '@gitbook/api';
import { resolveFirstDocument, resolvePagePath, resolvePagePathDocumentOrGroup } from './pages'; import { resolveFirstDocument, resolvePagePath, resolvePagePathDocumentOrGroup } from './pages';
@@ -28,11 +32,14 @@ describe('resolveFirstDocument', () => {
pages: [], pages: [],
layout: { layout: {
cover: true, cover: true,
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
title: true, title: true,
description: true, description: true,
tableOfContents: true, tableOfContents: true,
outline: true, outline: true,
pagination: true, pagination: true,
width: RevisionPageLayoutOptionsWidth.Default,
metadata: true,
}, },
}, },
], ],
@@ -75,11 +82,14 @@ describe('resolveFirstDocument', () => {
pages: [], pages: [],
layout: { layout: {
cover: true, cover: true,
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
title: true, title: true,
description: true, description: true,
tableOfContents: true, tableOfContents: true,
outline: true, outline: true,
pagination: true, pagination: true,
width: RevisionPageLayoutOptionsWidth.Default,
metadata: true,
}, },
}, },
]; ];
@@ -115,11 +125,14 @@ describe('resolvePagePath', () => {
pages: [], pages: [],
layout: { layout: {
cover: true, cover: true,
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
title: true, title: true,
description: true, description: true,
tableOfContents: true, tableOfContents: true,
outline: true, outline: true,
pagination: true, pagination: true,
width: RevisionPageLayoutOptionsWidth.Default,
metadata: true,
}, },
}, },
]; ];
@@ -185,11 +198,14 @@ describe('resolvePagePath', () => {
pages: [], pages: [],
layout: { layout: {
cover: true, cover: true,
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
title: true, title: true,
description: true, description: true,
tableOfContents: true, tableOfContents: true,
outline: true, outline: true,
pagination: true, pagination: true,
width: RevisionPageLayoutOptionsWidth.Default,
metadata: true,
}, },
}, },
], ],
+4 -4
View File
@@ -49,10 +49,6 @@ export function defaultCustomization(): api.SiteCustomizationSettings {
feedback: { feedback: {
enabled: false, enabled: false,
}, },
// TODO: remove aiSearch once the cache has been fully updated (after 11/07/2025)
aiSearch: {
enabled: true,
},
ai: { ai: {
mode: api.CustomizationAIMode.None, mode: api.CustomizationAIMode.None,
}, },
@@ -68,6 +64,10 @@ export function defaultCustomization(): api.SiteCustomizationSettings {
pagination: { pagination: {
enabled: true, enabled: true,
}, },
pageActions: {
externalAI: true,
markdown: true,
},
trademark: { trademark: {
enabled: true, enabled: true,
}, },
+7
View File
@@ -249,6 +249,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
shareKey: siteURLData.shareKey, shareKey: siteURLData.shareKey,
apiToken: siteURLData.apiToken, apiToken: siteURLData.apiToken,
imagesContextId: imagesContextId, imagesContextId: imagesContextId,
contextId: siteURLData.contextId,
}; };
const requestHeaders = new Headers(request.headers); const requestHeaders = new Headers(request.headers);
@@ -328,6 +329,12 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
response.headers.set('x-gitbook-route-type', routeType); response.headers.set('x-gitbook-route-type', routeType);
response.headers.set('x-gitbook-route-site', siteURLWithoutProtocol); response.headers.set('x-gitbook-route-site', siteURLWithoutProtocol);
// When we use adaptive content, we want to ensure that the cache is not used at all on the client side.
// Vercel already set this header, this is needed in OpenNext.
if (siteURLData.contextId) {
response.headers.set('cache-control', 'public, max-age=0, must-revalidate');
}
return writeResponseCookies(response, cookies); return writeResponseCookies(response, cookies);
}; };
+17 -12
View File
@@ -252,6 +252,8 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
height: 630, height: 630,
fonts: fonts.length ? fonts : undefined, fonts: fonts.length ? fonts : undefined,
headers: { headers: {
// We don't want to cache the image for too long in the browser
'cache-control': 'public, max-age=300, s-maxage=31536000',
'cache-tag': [ 'cache-tag': [
getCacheTag({ getCacheTag({
tag: 'site', tag: 'site',
@@ -366,19 +368,22 @@ const SUPPORTED_IMAGE_TYPES = [
async function fetchImage(url: string, options?: ResizeImageOptions) { async function fetchImage(url: string, options?: ResizeImageOptions) {
// Skip early some images to avoid fetching them // Skip early some images to avoid fetching them
const parsedURL = new URL(url); const parsedURL = new URL(url);
if (UNSUPPORTED_IMAGE_EXTENSIONS.includes(getExtension(parsedURL.pathname).toLowerCase())) {
return null;
}
// We use the image resizer to normalize the image format to PNG. let response: Response;
// as @vercel/og can sometimes fail on some JPEG images. if (
const response = UNSUPPORTED_IMAGE_EXTENSIONS.includes(getExtension(parsedURL.pathname).toLowerCase()) ||
checkIsSizableImageURL(url) !== SizableImageAction.Resize checkIsSizableImageURL(url) === SizableImageAction.Resize
? await fetch(url) ) {
: await resizeImage(url, { // We use the image resizer to normalize the image format to PNG.
...options, // as @vercel/og can sometimes fail on some JPEG images, and will fail on avif and webp images.
format: 'png', response = await resizeImage(url, {
}); ...options,
format: 'png',
bypassSkipCheck: true, // Bypass the check to see if the image can be resized
});
} else {
response = await fetch(url);
}
// Filter out unsupported image types // Filter out unsupported image types
const contentType = response.headers.get('content-type'); const contentType = response.headers.get('content-type');
+12 -3
View File
@@ -298,10 +298,16 @@ const config: Config = {
}, },
animation: { animation: {
present: 'present 200ms cubic-bezier(0.25, 1, 0.5, 1) both', present: 'present 200ms cubic-bezier(0.25, 1, 0.5, 1) both',
'present-slow': 'present 500ms cubic-bezier(0.25, 1, 0.5, 1) both',
scaleIn: 'scaleIn 200ms ease both', scaleIn: 'scaleIn 200ms ease both',
'scaleIn-slow': 'scaleIn 500ms ease both',
scaleOut: 'scaleOut 200ms ease both', scaleOut: 'scaleOut 200ms ease both',
'scaleOut-slow': 'scaleOut 500ms ease both',
fadeIn: 'fadeIn 200ms ease both', fadeIn: 'fadeIn 200ms ease both',
'fadeIn-slow': 'fadeIn 500ms ease both',
fadeOut: 'fadeOut 200ms ease both', fadeOut: 'fadeOut 200ms ease both',
'fadeOut-slow': 'fadeOut 500ms ease both',
enterFromLeft: 'enterFromLeft 250ms 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', enterFromRight: 'enterFromRight 250ms cubic-bezier(0.83, 0, 0.17, 1) both',
exitToLeft: 'exitToLeft 250ms cubic-bezier(0.83, 0, 0.17, 1) both', exitToLeft: 'exitToLeft 250ms cubic-bezier(0.83, 0, 0.17, 1) both',
@@ -516,6 +522,9 @@ const config: Config = {
'html:not(.announcement-hidden):has(#announcement-banner) &' 'html:not(.announcement-hidden):has(#announcement-banner) &'
); );
// Variant to target first-of-type in a column
addVariant('column-first-of-type', ':merge(.group\\/column) > &:first-of-type'); // optional for group-based variants
const customisationVariants = { const customisationVariants = {
// Sidebar styles // Sidebar styles
sidebar: ['sidebar-default', 'sidebar-filled'], sidebar: ['sidebar-default', 'sidebar-filled'],
@@ -561,9 +570,9 @@ const config: Config = {
/** /**
* Variant when the page contains a block that will be rendered in full-width mode. * Variant when the page contains a block that will be rendered in full-width mode.
*/ */
addVariant('site-full-width', 'body:has(.site-full-width) &'); addVariant('site-width-wide', 'body:has(.site-width-wide) &');
addVariant('site-default-width', 'body:has(.site-default-width) &'); addVariant('site-width-default', 'body:has(.site-width-default) &');
addVariant('page-full-width', 'body:has(.page-full-width) &'); addVariant('page-width-wide', 'body:has(.page-width-wide) &');
/** /**
* Variant when the page is configured to hide the table of content. * Variant when the page is configured to hide the table of content.
+3 -4
View File
@@ -58,12 +58,11 @@ export const Icon = React.forwardRef(function Icon(
{...rest} {...rest}
style={{ style={{
maskImage: `url(${url})`, maskImage: `url(${url})`,
//@ts-ignore // For compatibility with older browsers WebkitMaskImage: `url(${url})`,
'-webkit-mask-image': `url(${url})`,
maskRepeat: 'no-repeat', maskRepeat: 'no-repeat',
'-webkit-mask-repeat': 'no-repeat', WebkitMaskRepeat: 'no-repeat',
maskPosition: 'center', maskPosition: 'center',
'-webkit-mask-position': 'center', WebkitMaskPosition: 'center',
...(size ? { width: size, height: size } : {}), ...(size ? { width: size, height: size } : {}),
...rest.style, ...rest.style,
}} }}
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/openapi-parser # @gitbook/openapi-parser
## 2.2.2
### Patch Changes
- b5ad0ce: Fix OpenAPI v2 parsing
## 2.2.1 ## 2.2.1
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -9,7 +9,7 @@
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"version": "2.2.1", "version": "2.2.2",
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"@scalar/openapi-parser": "^0.18.0", "@scalar/openapi-parser": "^0.18.0",
+11 -1
View File
@@ -3,11 +3,12 @@ import { OpenAPIParseError } from './error';
import { parseOpenAPI } from './parse'; import { parseOpenAPI } from './parse';
const spec = await Bun.file(new URL('./fixtures/recursive-spec.json', import.meta.url)).text(); const spec = await Bun.file(new URL('./fixtures/recursive-spec.json', import.meta.url)).text();
const specV2 = await Bun.file(new URL('./fixtures/spec-v2.json', import.meta.url)).text();
const html = `<!DOCTYPE html><html lang=\"en\"><head> <meta charset=\"utf-8\"> <title>API Documentation</title> <base href=\"/api-docs/\"> <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"> <link rel=\"icon\" type=\"image/x-icon\" href=\"favicon.ico\"> <link rel=\"stylesheet\" href=\"https://use.fontawesome.com/releases/v5.7.0/css/all.css\" integrity=\"sha384-lZN37f5QGtY3VHgisS14W3ExzMWZxybE1SJSEsQp9S+oqd12jhcu+A56Ebc1zFSJ\" crossorigin=\"anonymous\"> <style>@import\"https://fonts.googleapis.com/css2?family=Nunito:wght@200&display=swap\";@charset \"UTF-8\";:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,\"Segoe UI\",Roboto,\"Helvetica Neue\",\"Noto Sans\",\"Liberation Sans\",Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\",\"Noto Color Emoji\";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, .15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, .175);--bs-border-radius:.375rem;--bs-border-radius-sm:.25rem;--bs-border-radius-lg:.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-2xl:2rem;--bs-border-radius-pill:50rem;--bs-link-color:#0d6efd;--bs-link-hover-color:#0a58ca;--bs-code-color:#d63384;--bs-highlight-bg:#fff3cd}*,:after,:before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}:root{--primaryColor1:#373e3e;--primaryColor2:#373e3e;--white:#ffffff;--lightGrey:#F8F9F9;--grey:#F8F9F9;--darkGrey:#6c757d;--black:black}*{box-sizing:border-box}body{font-family:Nunito,sans-serif;background-color:var(--grey);margin:5px auto}</style><link rel=\"stylesheet\" href=\"styles.css\" media=\"print\" onload=\"this.media='all'\"><noscript><link rel=\"stylesheet\" href=\"styles.css\"></noscript></head> <body> <app-root></app-root> <script> parent.postMessage(location.hash, location.origin); </script> <script src=\"runtime.js\" type=\"module\"></script><script src=\"polyfills.js\" type=\"module\"></script><script src=\"scripts.js\" defer></script><script src=\"main.js\" type=\"module\"></script> <script type=\"text/javascript\" src=\"/_Incapsula_Resource?SWJIYLWA=719d34d31c8e3a6e6fffd425f7e032f3&ns=2&cb=242625292\" async></script></body></html>`; const html = `<!DOCTYPE html><html lang=\"en\"><head> <meta charset=\"utf-8\"> <title>API Documentation</title> <base href=\"/api-docs/\"> <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"> <link rel=\"icon\" type=\"image/x-icon\" href=\"favicon.ico\"> <link rel=\"stylesheet\" href=\"https://use.fontawesome.com/releases/v5.7.0/css/all.css\" integrity=\"sha384-lZN37f5QGtY3VHgisS14W3ExzMWZxybE1SJSEsQp9S+oqd12jhcu+A56Ebc1zFSJ\" crossorigin=\"anonymous\"> <style>@import\"https://fonts.googleapis.com/css2?family=Nunito:wght@200&display=swap\";@charset \"UTF-8\";:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,\"Segoe UI\",Roboto,\"Helvetica Neue\",\"Noto Sans\",\"Liberation Sans\",Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\",\"Noto Color Emoji\";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, .15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, .175);--bs-border-radius:.375rem;--bs-border-radius-sm:.25rem;--bs-border-radius-lg:.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-2xl:2rem;--bs-border-radius-pill:50rem;--bs-link-color:#0d6efd;--bs-link-hover-color:#0a58ca;--bs-code-color:#d63384;--bs-highlight-bg:#fff3cd}*,:after,:before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}:root{--primaryColor1:#373e3e;--primaryColor2:#373e3e;--white:#ffffff;--lightGrey:#F8F9F9;--grey:#F8F9F9;--darkGrey:#6c757d;--black:black}*{box-sizing:border-box}body{font-family:Nunito,sans-serif;background-color:var(--grey);margin:5px auto}</style><link rel=\"stylesheet\" href=\"styles.css\" media=\"print\" onload=\"this.media='all'\"><noscript><link rel=\"stylesheet\" href=\"styles.css\"></noscript></head> <body> <app-root></app-root> <script> parent.postMessage(location.hash, location.origin); </script> <script src=\"runtime.js\" type=\"module\"></script><script src=\"polyfills.js\" type=\"module\"></script><script src=\"scripts.js\" defer></script><script src=\"main.js\" type=\"module\"></script> <script type=\"text/javascript\" src=\"/_Incapsula_Resource?SWJIYLWA=719d34d31c8e3a6e6fffd425f7e032f3&ns=2&cb=242625292\" async></script></body></html>`;
describe('#parseOpenAPI', () => { describe('#parseOpenAPI', () => {
it('parses an OpenAPI document', async () => { it('parses a recursive OpenAPI document', async () => {
const schema = await parseOpenAPI({ const schema = await parseOpenAPI({
value: spec, value: spec,
rootURL: null, rootURL: null,
@@ -16,6 +17,15 @@ describe('#parseOpenAPI', () => {
JSON.stringify(schema); JSON.stringify(schema);
}); });
it('parses a swagger v2', async () => {
const schema = await parseOpenAPI({
value: specV2,
rootURL: null,
});
// Ensure the structure returned is not recursive (not dereferenced).
JSON.stringify(schema);
});
it('throws an error for invalid OpenAPI document', async () => { it('throws an error for invalid OpenAPI document', async () => {
expect.assertions(1); expect.assertions(1);
try { try {
+1 -1
View File
@@ -28,5 +28,5 @@ export async function convertOpenAPIV2ToOpenAPIV3(
} }
})(); })();
return parseOpenAPIV3({ ...input, rootURL, value: upgradeResult.specification }); return parseOpenAPIV3({ ...input, rootURL, value: upgradeResult.specification, trust: true });
} }
+7 -7
View File
@@ -35,6 +35,13 @@ async function untrustedValidate(input: ValidateOpenAPIV3Input) {
const { value, rootURL } = input; const { value, rootURL } = input;
const result = await validate(value); const result = await validate(value);
if (result.version === '2.0') {
throw new OpenAPIParseError('Only OpenAPI v3 is supported', {
code: 'parse-v2-in-v3',
rootURL,
});
}
// Spec is invalid, we stop here. // Spec is invalid, we stop here.
if (!result.valid) { if (!result.valid) {
throw new OpenAPIParseError('Invalid OpenAPI document', { throw new OpenAPIParseError('Invalid OpenAPI document', {
@@ -44,13 +51,6 @@ async function untrustedValidate(input: ValidateOpenAPIV3Input) {
}); });
} }
if (result.version === '2.0') {
throw new OpenAPIParseError('Only OpenAPI v3 is supported', {
code: 'parse-v2-in-v3',
rootURL,
});
}
return result.specification; return result.specification;
} }
+97 -31
View File
@@ -18,41 +18,107 @@ export function ElementButton(
const clientContext = useContentKitClientContext(); const clientContext = useContentKitClientContext();
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const [confirm, setConfirm] = React.useState<boolean>(false);
// TODO: const wrapLoading = React.useCallback(async <T,>(fn: Promise<T> | (() => Promise<T>)) => {
// - confirm dialog setLoading(true);
try {
return fn instanceof Promise ? await fn : await fn();
} finally {
setLoading(false);
}
}, []);
return ( return (
<button <>
title={element.tooltip} <button
className={classNames( type="button"
'contentkit-button', title={element.tooltip}
`contentkit-button-style-${element.style ?? 'secondary'}` className={classNames(
)} 'contentkit-button',
onClick={(event) => { `contentkit-button-style-${element.style ?? 'secondary'}`
if (element.disabled || loading) { )}
return; onClick={(event) => {
} if (element.disabled || loading) {
return;
}
event.stopPropagation(); event.stopPropagation();
event.preventDefault();
setLoading(true); if (element.confirm && !confirm) {
clientContext.dispatchAction(element.onPress).finally(() => { setConfirm(true);
setLoading(false); return;
}); }
}}
> wrapLoading(async () => await clientContext.dispatchAction(element.onPress));
{loading ? ( }}
<Icon icon="spinner" className="contentkit-button-loading" /> >
) : ( {loading ? (
<> <Icon icon="spinner" className="contentkit-button-loading" />
{icon} ) : (
{element.label ? ( <>
<span className="contentkit-button-label">{element.label}</span> {icon}
) : null} {element.label ? (
{trailingIcon} <span className="contentkit-button-label">{element.label}</span>
</> ) : null}
)} {trailingIcon}
</button> </>
)}
</button>
{element.confirm && confirm ? (
<ConfirmDialog
open={confirm}
{...element.confirm}
onConfirm={() => {
setConfirm(false);
wrapLoading(
async () => await clientContext.dispatchAction(element.onPress)
);
}}
onCancel={() => setConfirm(false)}
/>
) : null}
</>
);
}
function ConfirmDialog({ open, onCancel, onConfirm, style, title, text, confirm }: any) {
return (
<div className="contentkit-modal-backdrop" onClick={onCancel}>
<div
className={classNames(
'contentkit-modal contentkit-modal-confirm',
open ? 'contentkit-modal-opened' : null
)}
onClick={(event) => {
event.stopPropagation();
}}
>
<div className="contentkit-modal-header">
{title ? <h1 className="contentkit-modal-title">{title}</h1> : null}
{text ? <div className="contentkit-modal-subtitle">{text}</div> : null}
</div>
<div className="contentkit-modal-footer">
<button
type="button"
className="contentkit-button contentkit-button-confirm contentkit-button-style-secondary"
onClick={onCancel}
>
Cancel
</button>
<button
type="button"
className={classNames(
'contentkit-button contentkit-button-confirm',
`contentkit-button-style-${style ?? 'primary'}`
)}
onClick={onConfirm}
>
{confirm ?? 'OK'}
</button>
</div>
</div>
</div>
); );
} }
+7
View File
@@ -1,5 +1,12 @@
# @gitbook/react-openapi # @gitbook/react-openapi
## 1.3.4
### Patch Changes
- Updated dependencies [b5ad0ce]
- @gitbook/openapi-parser@2.2.2
## 1.3.3 ## 1.3.3
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"version": "1.3.3", "version": "1.3.4",
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"@gitbook/openapi-parser": "workspace:*", "@gitbook/openapi-parser": "workspace:*",