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": {
"name": "gitbook",
"version": "0.13.1",
"version": "0.14.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/cache-tags": "workspace:*",
@@ -146,7 +146,7 @@
},
"packages/icons": {
"name": "@gitbook/icons",
"version": "0.2.0",
"version": "0.2.1",
"bin": {
"gitbook-icons": "./bin/gitbook-icons.js",
},
@@ -166,7 +166,7 @@
},
"packages/openapi-parser": {
"name": "@gitbook/openapi-parser",
"version": "2.2.0",
"version": "2.2.1",
"dependencies": {
"@scalar/openapi-parser": "^0.18.0",
"@scalar/openapi-types": "^0.1.9",
@@ -181,7 +181,7 @@
},
"packages/react-contentkit": {
"name": "@gitbook/react-contentkit",
"version": "0.7.1",
"version": "0.7.2",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:*",
@@ -213,7 +213,7 @@
},
"packages/react-openapi": {
"name": "@gitbook/react-openapi",
"version": "1.3.2",
"version": "1.3.3",
"dependencies": {
"@gitbook/openapi-parser": "workspace:*",
"@scalar/api-client-react": "^1.3.16",
@@ -246,7 +246,7 @@
"react-dom": "^19.0.0",
},
"catalog": {
"@gitbook/api": "^0.128.0",
"@gitbook/api": "^0.129.0",
},
"packages": {
"@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=="],
"@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"],
+1 -1
View File
@@ -34,7 +34,7 @@
"workspaces": {
"packages": ["packages/*"],
"catalog": {
"@gitbook/api": "^0.128.0"
"@gitbook/api": "^0.129.0"
}
},
"patchedDependencies": {
+8 -4
View File
@@ -90,7 +90,7 @@ export function getCacheTag(
| {
tag: 'translation';
organization: string;
translationSettings: string;
translation: string;
}
): string {
switch (spec.tag) {
@@ -115,7 +115,7 @@ export function getCacheTag(
case 'openapi':
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
case 'translation':
return `organization:${spec.organization}:translation:${spec.translationSettings}`;
return `organization:${spec.organization}:translation:${spec.translation}`;
default:
assertNever(spec);
}
@@ -144,6 +144,10 @@ export function getComputedContentSourceCacheTags(
) {
const tags: string[] = [];
if (!('dependencies' in source)) {
return tags;
}
// We add the dependencies as tags, to ensure that the computed content is invalidated
// when the dependencies are updated.
const dependencies = Object.values(source.dependencies ?? {});
@@ -167,12 +171,12 @@ export function getComputedContentSourceCacheTags(
})
);
break;
case 'translation-language':
case 'translation':
tags.push(
getCacheTag({
tag: 'translation',
organization: inContext.organizationId,
translationSettings: dependency.ref.translationSettings,
translation: dependency.ref.translation,
})
);
break;
+17
View File
@@ -1,5 +1,22 @@
# 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
### 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',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
+4 -4
View File
@@ -318,10 +318,6 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
feedback: {
enabled: false,
},
// TODO: remove aiSearch once the cache has been fully updated (after 11/07/2025)
aiSearch: {
enabled: true,
},
ai: {
mode: CustomizationAIMode.None,
},
@@ -337,6 +333,10 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
pagination: {
enabled: true,
},
pageActions: {
externalAI: true,
markdown: true,
},
trademark: {
enabled: true,
},
+3
View File
@@ -14,6 +14,9 @@ const nextConfig = {
dynamic: 3600, // 1 hour
static: 3600, // 1 hour
},
// Since content is fully static, we don't want to fetch on hover again
optimisticClientCache: false,
},
env: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.14.0",
"version": "0.14.1",
"private": true,
"dependencies": {
"@gitbook/api": "catalog:",
@@ -1,4 +1,5 @@
import type { GitBookSiteContext } from '@/lib/context';
import { tcls } from '@/lib/tailwind';
import type { AIMessage } from '@gitbook/api';
import { DocumentView } from '../../DocumentView';
import { AIToolCallsSummary } from './AIToolCallsSummary';
@@ -19,7 +20,13 @@ export function AIMessageView(
<div className="flex flex-col gap-2">
{message.steps.map((step, index) => {
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
document={step.content}
context={{
@@ -28,8 +35,9 @@ export function AIMessageView(
wrapBlocksInSuspense: false,
shouldRenderLinkPreviews: true,
}}
style={['space-y-4']}
style="mt-2 space-y-4 empty:hidden"
/>
{renderToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
<AIToolCallsSummary toolCalls={step.toolCalls} context={context} />
) : null}
@@ -4,6 +4,7 @@ import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import type { GitBookSiteContext } from '@/lib/context';
import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import type {
AIToolCall,
AIToolCallGetPageContent,
@@ -37,7 +38,7 @@ function ToolCallSummary(props: { toolCall: AIToolCall; context: GitBookSiteCont
const { toolCall, context } = props;
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={getIconForToolCall(toolCall)}
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 (
<details className="-ml-5 group flex w-full flex-col 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">
<div className="flex flex-col">
<details className={tcls('-ml-5 group flex w-full flex-col', hasResults ? 'gap-2' : '')}>
<summary
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 className="text-tint-subtle text-xs">
{toolCall.results.length
<p className="mt-0.5 text-tint-subtle text-xs">
{hasResults
? t(language, 'search_results_count', toolCall.results.length)
: t(language, 'search_no_results')}
</p>
</div>
<div className="ml-auto flex items-center gap-1">
<span className="block group-open:hidden">{t(language, 'view')}</span>
<span className="hidden group-open:block">{t(language, 'close')}</span>
<Icon
icon="chevron-right"
className="size-3 shrink-0 transition-transform group-open:rotate-90"
/>
</div>
{hasResults ? (
<div className="ml-auto flex items-center gap-1">
<span className="block group-open:hidden">{t(language, 'view')}</span>
<span className="hidden group-open:block">{t(language, 'close')}</span>
<Icon
icon="chevron-right"
className="size-3 shrink-0 transition-transform group-open:rotate-90"
/>
</div>
) : null}
</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">
<ol className="space-y-1">
{searchResultsWithHrefs.map((result, index) => (
<li
key={`${result.pageId}-${index}`}
className="animate-fadeIn"
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"
{hasResults ? (
<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">
<ol className="space-y-1">
{searchResultsWithHrefs.map((result, index) => (
<li
key={`${result.pageId}-${index}`}
className="animate-fadeIn-slow"
style={{
animationDelay: `${index * 25}ms`,
}}
>
<Icon
icon="memo"
className="mt-1 size-3 shrink-0 text-tint-subtle"
/>
<div className="flex flex-col gap-1 text-tint">
<h3 className="line-clamp-2 font-medium text-sm text-tint">
<HighlightQuery
query={toolCall.query}
text={result.title}
/>
</h3>
{result.description && (
<p className="line-clamp-2 text-tint-subtle text-xs">
<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
icon="memo"
className="mt-1 size-3 shrink-0 text-tint-subtle"
/>
<div className="flex flex-col gap-1 text-tint">
<h3 className="line-clamp-2 font-medium text-sm text-tint">
<HighlightQuery
query={toolCall.query}
text={result.description}
text={result.title}
/>
</p>
)}
</div>
<Icon
icon="chevron-right"
className="ml-auto size-3 shrink-0 self-center"
/>
</Link>
</li>
))}
</ol>
</div>
</h3>
{result.description && (
<p className="line-clamp-2 text-tint-subtle text-xs">
<HighlightQuery
query={toolCall.query}
text={result.description}
/>
</p>
)}
</div>
<Icon
icon="chevron-right"
className="ml-auto size-3 shrink-0 self-center"
/>
</Link>
</li>
))}
</ol>
</div>
) : null}
</details>
);
}
@@ -11,6 +11,7 @@ import { useAIMessageContextRef } from './useAIMessageContext';
export type AIChatMessage = {
role: AIMessageRole;
content: React.ReactNode;
query?: string;
};
export type AIChatState = {
@@ -24,6 +25,11 @@ export type AIChatState = {
*/
responseId: string | null;
/**
* The latest query sent to the AI.
*/
query: string | null;
/**
* Messages in the session.
*/
@@ -73,6 +79,7 @@ const globalState = zustand.create<{
opened: false,
responseId: null,
messages: [],
query: null,
followUpSuggestions: [],
loading: false,
error: false,
@@ -106,6 +113,7 @@ export function useAIChatController(): AIChatController {
opened: state.opened,
loading: false,
messages: [],
query: null,
followUpSuggestions: [],
responseId: null,
error: false,
@@ -128,6 +136,7 @@ export function useAIChatController(): AIChatController {
content: null,
},
],
query: input.message,
followUpSuggestions: [],
loading: true,
error: false,
@@ -232,7 +232,7 @@ function AIActionWrapper(props: {
size="xsmall"
variant="secondary"
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}
href={href}
target={href ? '_blank' : undefined}
@@ -6,8 +6,9 @@ import {
OpenInLLM,
ViewAsMarkdown,
} from '@/components/AIActions/AIActions';
import { Button } from '@/components/primitives/Button';
import { DropdownMenu } from '@/components/primitives/DropdownMenu';
import { Button, ButtonGroup } from '@/components/primitives/Button';
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
import type { SiteCustomizationSettings } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { useRef } from 'react';
@@ -16,10 +17,7 @@ interface AIActionsDropdownProps {
markdownPageUrl: string;
withAIChat?: boolean;
trademark: boolean;
/**
* Whether to include the "Open in LLM" entries in the dropdown menu.
*/
withLLMActions?: boolean;
actions: SiteCustomizationSettings['pageActions'];
}
/**
@@ -29,29 +27,31 @@ export function AIActionsDropdown(props: AIActionsDropdownProps) {
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref} className="flex h-fit items-stretch justify-start">
<ButtonGroup ref={ref}>
<DefaultAction {...props} />
<DropdownMenu
align="end"
className="!min-w-60 max-w-max"
button={
<Button
icon={
<Icon
icon="chevron-down"
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
/>
}
iconOnly
size="xsmall"
variant="secondary"
className="hover:!scale-100 hover:!translate-y-0 !shadow-none !rounded-l-none bg-tint-base text-sm"
/>
}
>
<AIActionsDropdownMenuContent {...props} />
</DropdownMenu>
</div>
{props.actions.markdown || props.actions.externalAI ? (
<DropdownMenu
align="end"
className="!min-w-60 max-w-max"
button={
<Button
icon={
<Icon
icon="chevron-down"
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
/>
}
iconOnly
size="xsmall"
variant="secondary"
className="bg-tint-base text-sm"
/>
}
>
<AIActionsDropdownMenuContent {...props} />
</DropdownMenu>
) : null}
</ButtonGroup>
);
}
@@ -59,7 +59,7 @@ export function AIActionsDropdown(props: AIActionsDropdownProps) {
* The content of the dropdown menu.
*/
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
const { markdownPageUrl, withAIChat, trademark, withLLMActions } = props;
const { markdownPageUrl, withAIChat, trademark, actions } = props;
return (
<>
@@ -67,15 +67,21 @@ function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
<OpenDocsAssistant trademark={trademark} type="dropdown-menu-item" />
) : null}
<CopyMarkdown
isDefaultAction={!withAIChat}
markdownPageUrl={markdownPageUrl}
type="dropdown-menu-item"
/>
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
{withLLMActions ? (
{actions.markdown ? (
<>
<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="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
*/
function DefaultAction(props: AIActionsDropdownProps) {
const { markdownPageUrl, withAIChat, trademark } = props;
const { markdownPageUrl, withAIChat, trademark, actions } = props;
if (withAIChat) {
return <OpenDocsAssistant trademark={trademark} type="button" />;
}
return (
<CopyMarkdown
isDefaultAction={!withAIChat}
markdownPageUrl={markdownPageUrl}
type="button"
/>
);
if (actions.markdown) {
return (
<CopyMarkdown
isDefaultAction={!withAIChat}
markdownPageUrl={markdownPageUrl}
type="button"
/>
);
}
if (actions.externalAI) {
return (
<>
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="button" />
</>
);
}
}
@@ -112,7 +112,7 @@ export function AIChatWindow(props: {
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="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
className="size-5 text-tint"
trademark={trademark}
@@ -149,7 +149,7 @@ export function AIChatWindow(props: {
onClick={() => {}}
iconOnly
icon="ellipsis"
label={tString(language, 'more')}
label={tString(language, 'actions')}
className="!px-2"
variant="blank"
size="default"
@@ -162,7 +162,10 @@ export function AIChatWindow(props: {
}}
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')}
</DropdownMenuItem>
</DropdownMenu>
@@ -186,7 +189,7 @@ export function AIChatWindow(props: {
>
{isEmpty ? (
<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
state="intro"
trademark={trademark}
@@ -215,7 +218,7 @@ export function AIChatWindow(props: {
</div>
<div
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. */}
{chat.error ? <AIChatError chatController={chatController} /> : null}
@@ -241,7 +244,7 @@ function AIChatError(props: { chatController: AIChatController }) {
const { chatController } = props;
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">
<Icon icon="exclamation-triangle" className="size-3.5" />
<span className="flex items-center gap-1">{t(language, 'ai_chat_error')}</span>
@@ -1,4 +1,6 @@
'use client';
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
import { Button } from '../primitives';
@@ -13,6 +15,7 @@ export function AIChatButton(props: { trademark: boolean }) {
const { trademark } = props;
const chatController = useAIChatController();
const chat = useAIChatState();
const language = useLanguage();
return (
<Button
@@ -23,7 +26,7 @@ export function AIChatButton(props: { trademark: boolean }) {
className={tcls('h-9 px-2.5')}
label={
<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" />
</div>
}
@@ -54,7 +54,7 @@ export const AIChatIcon = ({
state === 'intro' &&
'animate-[fadeIn_.5s_.7s_both,spin_2s_1s_forwards_cubic-bezier(.43,1.54,.64,1)]',
(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 === 'default' && 'animate-[fadeIn_0s_both]',
state === 'error' && 'hidden'
@@ -64,7 +64,7 @@ export function AIChatInput(props: {
'focus:outline-none',
'focus:ring-0',
'w-full',
'px-4',
'px-3',
'py-3',
'pb-12',
'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']} />
</div>
<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 type React from 'react';
import type { AIChatController, AIChatState } from '../AI/useAIChat';
import { AIResponseFeedback } from './AIResponseFeedback';
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
export function AIChatMessages(props: {
@@ -23,29 +24,30 @@ export function AIChatMessages(props: {
<div
ref={isLastUserMessage ? lastUserMessageRef : undefined}
className={tcls(
message.content ? 'animate-[fadeIn_500ms_both]' : '',
message.content ? 'animate-fadeIn-slow' : '',
'shrink-0',
'last:min-h-[calc(100%-5rem)]',
'scroll-mt-36',
'lg:scroll-mt-0',
'flex flex-col gap-6',
'group/message',
message.role === AIMessageRole.User
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
: ''
: 'text-tint-strong'
)}
style={{
animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
}}
key={index}
>
{message.content ? (
message.content
) : chat.loading ? (
<div className="flex w-full animate-[fadeIn_500ms_both] flex-wrap gap-2">
{message.content ? message.content : null}
{isLastMessage && chat.loading ? (
<div className="flex w-full animate-fadeIn-slow flex-wrap gap-2 group-has-[.has-content]/message:hidden">
{Array.from({ length: 7 }).map((_, index) => (
<div
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={{
width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
animationDelay: `${index * 0.1}s`,
@@ -54,11 +56,21 @@ export function AIChatMessages(props: {
))}
</div>
) : null}
{isLastMessage ? (
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
/>
<>
{!chat.loading && !chat.error && chat.query && chat.responseId && (
<AIResponseFeedback
responseId={chat.responseId}
query={chat.query}
className="-ml-1 -mt-4"
/>
)}
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
/>
</>
) : null}
</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 (
<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) => (
<Button
key={index}
@@ -23,7 +23,7 @@ export function AIChatFollowupSuggestions(props: {
chatController.postMessage({ message: 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"
variant="blank"
style={{
@@ -1,3 +1,4 @@
export * from './AIChat';
export * from './AIChatButton';
export * from './AIChatIcon';
export * from './AIResponseFeedback';
@@ -29,7 +29,11 @@ export function AnnouncementBanner(props: {
const style = BANNER_STYLES[announcement.style];
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="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className={tcls('relative', CONTAINER_STYLE)}>
@@ -47,6 +47,23 @@ type UnwrappedBlocksProps<TBlock extends DocumentBlock> = DocumentContextProps &
isOffscreen?: boolean;
};
/* Blocks that can be full width are automatically expanded on full-width pages.
* Ideally we'd rely on the block type to determine if it can be full width, but
* the block's `fullWidth` property does not differentiate between `undefined` and `false`.
* So instead we hardcode a list of blocks that can be full width. */
const FULL_WIDTH_BLOCKS = [
'table',
'tabs',
'integration',
'openapi',
'images',
'embed',
'columns',
'code',
'content-ref',
'hint',
];
/**
* Renders a list of blocks without a wrapper element.
*/
@@ -68,10 +85,11 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
key={node.key || `${node.type}-${index}`}
block={node}
style={[
'mx-auto w-full decoration-primary/6',
'mx-auto page-width-wide:mx-0 w-full decoration-primary/6',
node.data && 'fullWidth' in node.data && node.data.fullWidth
? 'max-w-screen-2xl'
: 'page-full-width:ml-0 max-w-3xl',
: 'max-w-3xl',
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-screen-2xl',
blockStyle,
]}
isEstimatedOffscreen={isOffscreen}
@@ -18,7 +18,7 @@ export function Columns(props: BlockProps<DocumentBlockColumns>) {
document={document}
ancestorBlocks={[...ancestorBlocks, block, columnBlock]}
context={context}
style="w-full space-y-4 *:max-w-full"
style="group/column w-full space-y-4 *:max-w-full"
/>
</Column>
);
@@ -7,5 +7,5 @@ import type { BlockProps } from './Block';
export function Divider(props: BlockProps<DocumentBlockDivider>) {
const { style } = props;
return <hr className={tcls(style, 'page-full-width:max-w-full border-tint-subtle')} />;
return <hr className={tcls(style, 'page-width-wide:max-w-full border-tint-subtle')} />;
}
@@ -24,28 +24,32 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
className={tcls(
textStyle.textSize,
'heading',
'grid',
'flex',
'items-baseline',
'scroll-m-12',
getTextAlignment(block.data.align),
hashLinkButtonWrapperStyles,
style
style,
textStyle.marginTop
)}
>
<HashLinkButton
id={id}
block={block}
className={tcls('-ml-6', textStyle.anchorButtonMarginTop)}
className={tcls('-ml-6', 'pr-2')}
iconClassName={tcls('size-4')}
label="Direct link to heading"
/>
<div
className={tcls(
'grid-area-1-1',
'flex-1',
'z-[1]',
'justify-self-start',
'max-w-full',
'break-words',
getTextAlignment(block.data.align),
textStyle.lineHeight,
textStyle.marginTop
textStyle.lineHeight
)}
>
<Inlines {...rest} context={context} nodes={block.nodes} ancestorInlines={[]} />
@@ -52,8 +52,10 @@ export function Hint(props: BlockProps<DocumentBlockHint>) {
{hasHeading ? (
<Block
style={tcls(
'flip-heading-hash p-4 pl-3 text-[1em] *:mt-0',
hasHeading ? hintStyle.header : null
'!py-4 w-full items-start pl-3 text-[1em] *:flex-none',
// Heading hash styles
'flip-heading-hash pr-8',
hintStyle.header
)}
ancestorBlocks={[...ancestorBlocks, block]}
{...contextProps}
@@ -195,3 +195,18 @@
.contentkit-hstack > .contentkit-divider-large {
@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',
// remove margin-top for the first heading in a list
'[&:is(h2)>div]:mt-0',
'[&:is(h3)>div]:mt-0',
'[&:is(h4)>div]:mt-0',
'[&:is(h2)]:pt-0',
'[&:is(h3)]:pt-0',
'[&:is(h4)]:pt-0',
// Override the "mx-auto" class from UnwrappedBlocks
'mx-0'
)}
@@ -61,7 +61,7 @@ export function getOpenAPIContext(args: {
style={tcls([
headingProps.deprecated ? 'line-through' : undefined,
headingProps.deprecated || !!headingProps.stability
? '[&>div]:mt-0'
? '[&>div]:pt-0'
: undefined,
])}
block={{
@@ -58,7 +58,7 @@ export async function RecordCard(
'[&_.heading>div:first-child]:hidden',
'[&_.heading>div]:text-[.8em]',
'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:
// - 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: [],
blue: ['text-blue-500'],
red: ['text-red-500'],
@@ -134,9 +152,14 @@ const textColorToStyle: { [color in DocumentMarkColor['data']['text']]: ClassVal
yellow: ['text-yellow-600'],
purple: ['text-purple-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: [],
blue: ['bg-mark-blue'],
red: ['bg-mark-red'],
@@ -144,4 +167,9 @@ const backgroundColorToStyle: { [color in DocumentMarkColor['data']['background'
yellow: ['bg-mark-yellow'],
purple: ['bg-mark-purple'],
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;
/** Tailwind class for the margin top (mt-*) */
marginTop?: string;
/** Tailwind class for the margin top to apply on the anchor link button */
anchorButtonMarginTop?: string;
} {
switch (block.type) {
case 'paragraph':
@@ -23,22 +21,19 @@ export function getBlockTextStyle(block: DocumentBlock): {
return {
textSize: 'text-3xl font-semibold',
lineHeight: 'leading-tight',
marginTop: 'mt-[1em]',
anchorButtonMarginTop: 'mt-[1.05em]',
marginTop: 'column-first-of-type:pt-0 pt-[1em]',
};
case 'heading-2':
return {
textSize: 'text-2xl font-semibold',
lineHeight: 'leading-snug',
marginTop: 'mt-[0.75em]',
anchorButtonMarginTop: 'mt-[0.9em]',
marginTop: 'column-first-of-type:pt-0 pt-[0.75em]',
};
case 'heading-3':
return {
textSize: 'text-xl font-semibold',
lineHeight: 'leading-snug',
marginTop: 'mt-[0.5em]',
anchorButtonMarginTop: 'mt-[0.65em]',
marginTop: 'column-first-of-type:pt-0 pt-[0.5em]',
};
case 'divider':
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(
'lg:!max-w-none mx-auto grid max-w-3xl site-full-width:max-w-screen-2xl justify-between gap-12',
'lg:!max-w-none mx-auto grid max-w-3xl site-width-wide:max-w-screen-2xl justify-between gap-12',
'grid-cols-[auto_auto]',
'lg:grid-cols-[18rem_minmax(auto,_48rem)_auto]',
'xl:grid-cols-[18rem_minmax(auto,_48rem)_14rem]',
'site-full-width:lg:grid-cols-[18rem_minmax(auto,_80rem)_auto]',
'site-full-width:xl:grid-cols-[18rem_minmax(auto,_80rem)_14rem]',
'site-width-wide:lg:grid-cols-[18rem_minmax(auto,_80rem)_auto]',
'site-width-wide:xl:grid-cols-[18rem_minmax(auto,_80rem)_14rem]',
'page-no-toc:lg:grid-cols-[minmax(auto,_48rem)_auto]',
'page-no-toc:xl:grid-cols-[14rem_minmax(auto,_48rem)_14rem]',
'[body:has(.site-full-width,.page-no-toc)_&]:lg:grid-cols-[minmax(auto,_90rem)_auto]',
'[body:has(.site-full-width,.page-no-toc)_&]:xl:grid-cols-[14rem_minmax(auto,_90rem)_14rem]'
'[body:has(.site-width-wide,.page-no-toc)_&]:lg:grid-cols-[minmax(auto,_90rem)_auto]',
'[body:has(.site-width-wide,.page-no-toc)_&]:xl:grid-cols-[14rem_minmax(auto,_90rem)_14rem]'
)}
>
{
@@ -106,7 +106,7 @@ export function Footer(props: { context: GitBookSiteContext }) {
'col-span-2 page-has-toc:lg:col-span-1 page-has-toc:lg:col-start-2 page-no-toc:xl:col-span-1 page-no-toc:xl:col-start-2'
)}
>
<div className="mx-auto flex max-w-3xl site-full-width:max-w-screen-2xl flex-col gap-10 sm:flex-row sm:gap-6">
<div className="mx-auto flex max-w-3xl site-width-wide:max-w-screen-2xl flex-col gap-10 sm:flex-row sm:gap-6">
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
(column, columnIndex) => (
<div
@@ -55,7 +55,7 @@ export function PageAside(props: {
'chat-open:xl:max-3xl:max-w-0',
'chat-open:xl:max-3xl:ml-0',
'motion-safe:transition-all motion-safe:duration-300',
'motion-safe:xl:transition-all motion-safe:xl:duration-300',
'motion-safe:[transition-behavior:allow-discrete]',
'flex-col',
@@ -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:rounded-md',
'page-api-block:xl:max-2xl:h-auto',
'page-api-block:xl:max-2xl:my-4',
'page-api-block:p-2'
'page-api-block:xl:max-2xl:my-9',
'page-api-block:px-2',
'page-api-block:py-1.5'
)}
>
{page.layout.outline ? (
@@ -40,8 +40,8 @@ export function PageBody(props: {
LINK_PREVIEW_MAX_COUNT
)
: false;
const pageFullWidth = page.id === 'wtthNFMqmEQmnt5LKR0q';
const asFullWidth = pageFullWidth || contentFullWidth;
const pageWidthWide = page.layout.width === 'wide';
const siteWidthWide = pageWidthWide || contentFullWidth;
const language = getSpaceLanguage(customization);
const updatedAt = page.updatedAt ?? page.createdAt;
@@ -53,12 +53,12 @@ export function PageBody(props: {
'mx-auto max-w-screen-2xl py-8',
// Allow words to break if they are too long.
'break-anywhere',
pageFullWidth ? 'page-full-width 2xl:px-8' : 'page-default-width',
asFullWidth ? 'site-full-width' : 'site-default-width',
pageWidthWide ? 'page-width-wide 2xl:px-8' : 'page-width-default',
siteWidthWide ? 'site-width-wide' : 'site-width-default',
page.layout.tableOfContents ? 'page-has-toc' : 'page-no-toc'
)}
>
<PreservePageLayout asFullWidth={asFullWidth} />
<PreservePageLayout siteWidthWide={siteWidthWide} />
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
<PageCover as="hero" page={page} cover={page.cover} context={context} />
) : null}
@@ -93,19 +93,28 @@ export function PageBody(props: {
<PageFooterNavigation context={context} page={page} />
) : null}
<div className="mx-auto mt-6 page-api-block:ml-0 flex max-w-3xl page-full-width:max-w-screen-2xl flex-row flex-wrap items-center gap-4 text-tint contrast-more:text-tint-strong">
{updatedAt ? (
<p className="mr-auto text-sm">
{t(language, 'page_last_modified', <DateRelative value={updatedAt} />)}
</p>
) : null}
{withPageFeedback ? (
<PageFeedbackForm
className={page.layout.outline ? 'xl:hidden' : ''}
pageId={page.id}
/>
) : null}
</div>
{
// TODO: after 25/07/2025, we can chage it to a true check as the cache will be updated
page.layout.metadata !== false ? (
<div className="mx-auto mt-6 page-api-block:ml-0 flex max-w-3xl page-full-width:max-w-screen-2xl flex-row flex-wrap items-center gap-4 text-tint contrast-more:text-tint-strong">
{updatedAt ? (
<p className="mr-auto text-sm ">
{t(
language,
'page_last_modified',
<DateRelative value={updatedAt} />
)}
</p>
) : null}
{withPageFeedback ? (
<PageFeedbackForm
className={page.layout.outline ? 'xl:hidden' : ''}
pageId={page.id}
/>
) : null}
</div>
) : null
}
</main>
<TrackPageViewEvent />
@@ -71,11 +71,13 @@ export async function PageBodyBlankslate(props: {
className={tcls(
'grid',
'max-w-3xl',
'page-width-wide:max-w-screen-2xl',
'w-full',
'mx-auto',
'gap-4',
'grid-cols-1',
'sm:grid-cols-2'
'sm:grid-cols-2',
'page-width-wide:md:grid-cols-3'
)}
>
{pageElements}
@@ -95,7 +95,7 @@ export async function PageCover(props: {
: [
'sm:mx-auto',
'max-w-3xl ',
'page-full-width:max-w-screen-2xl',
'page-width-wide:max-w-screen-2xl',
'sm:rounded-md',
'mb-8',
]
@@ -34,7 +34,7 @@ export async function PageFooterNavigation(props: {
'mt-6',
'gap-2',
'max-w-3xl',
'page-full-width:max-w-screen-2xl',
'page-width-wide:max-w-screen-2xl',
'mx-auto',
'text-tint'
)}
@@ -3,7 +3,7 @@ import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
import type { GitBookSiteContext } from '@/lib/context';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { type RevisionPageDocument, SiteVisibility } from '@gitbook/api';
import type { RevisionPageDocument } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { PageIcon } from '../PageIcon';
import { StyledLink } from '../primitives';
@@ -22,11 +22,17 @@ export async function PageHeader(props: {
const withAIChat = isAIChatEnabled(context);
const pageActions = context.customization.pageActions ?? {
// TODO: After 25/07/2025, we can remove this default values as the cache will be updated
markdown: true,
externalAI: true,
};
return (
<header
className={tcls(
'max-w-3xl',
'page-full-width:max-w-screen-2xl',
'page-width-wide:max-w-screen-2xl',
'mx-auto',
'mb-6',
'space-y-3',
@@ -34,10 +40,12 @@ export async function PageHeader(props: {
'page-api-block:max-w-full'
)}
>
{page.layout.tableOfContents ? (
{page.layout.tableOfContents &&
// Show page actions if *any* of the actions are enabled
(withAIChat || pageActions.markdown || pageActions.externalAI) ? (
<div
className={tcls(
'float-right mb-2 ml-4',
'float-right mb-2 ml-4 page-api-block:xl:max-2xl:mr-[14.5rem]',
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`}
withAIChat={withAIChat}
trademark={context.customization.trademark.enabled}
withLLMActions={context.site.visibility === SiteVisibility.Public}
actions={pageActions}
/>
</div>
) : null}
@@ -6,14 +6,14 @@ import * as React from 'react';
* This approach is needed as page layout (full width block) is done using CSS (`body:has(.full-width)`),
* which becomes false while transitioning between the 2 page states:
*
* 1. Page 1 with full width block: `body:has(.site-full-width)` is true
* 2. Loading skeleton while transitioning to page 2: `body:has(.site-full-width)` is false
* 3. Page 2 with full width block: `body:has(.site-full-width)` is true
* 1. Page 1 with full width block: `body:has(.site-width-wide)` is true
* 2. Loading skeleton while transitioning to page 2: `body:has(.site-width-wide)` is false
* 3. Page 2 with full width block: `body:has(.site-width-wide)` is true
*
* This component ensures that the layout is preserved while transitioning between the 2 page states (in step 2).
*/
export function PreservePageLayout(props: { asFullWidth: boolean }) {
const { asFullWidth } = props;
export function PreservePageLayout(props: { siteWidthWide: boolean }) {
const { siteWidthWide } = props;
React.useLayoutEffect(() => {
// We use the header as it's an element preserved between page transitions
@@ -23,12 +23,12 @@ export function PreservePageLayout(props: { asFullWidth: boolean }) {
return;
}
if (asFullWidth) {
header.classList.add('site-full-width');
if (siteWidthWide) {
header.classList.add('site-width-wide');
} else {
header.classList.remove('site-full-width');
header.classList.remove('site-width-wide');
}
}, [asFullWidth]);
}, [siteWidthWide]);
return null;
}
@@ -54,10 +54,9 @@
}
.flip-heading-hash {
@apply [&:is(h1,h2,h3,h4)]:content-start;
@apply [&:is(h1,h2,h3,h4)]:[grid-auto-columns:auto_1fr];
@apply [&:is(h1,h2,h3,h4)>div:first-child]:[grid-area:1/2];
@apply [&:is(h1,h2,h3,h4)>div:first-child]:ml-1;
@apply [&:is(h1,h2,h3,h4)]:flex-row-reverse;
@apply [&:is(h1,h2,h3,h4)]:[justify-content:start];
@apply [&:is(h1,h2,h3,h4)>div:first-child]:ml-2;
}
}
@@ -10,6 +10,7 @@ import { t } from '@/intl/translate';
import type { TranslationLanguage } from '@/intl/translations';
import { tcls } from '@/lib/tailwind';
import { AIResponseFeedback } from '../AIChat';
import { useTrackEvent } from '../Insights';
import { Link } from '../primitives';
import { useSearchAskContext } from './SearchAskContext';
@@ -97,7 +98,11 @@ export function SearchAskAnswer(props: { query: string }) {
<div className="flex min-h-full p-4">
{askState?.type === 'answer' ? (
<React.Suspense fallback={loading}>
<TransitionAnswerBody answer={askState.answer} placeholder={loading} />
<TransitionAnswerBody
answer={askState.answer}
placeholder={loading}
query={query}
/>
</React.Suspense>
) : null}
{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,
* we need to wrap it in a transition to avoid flickering.
*/
function TransitionAnswerBody(props: { answer: AskAnswerResult; placeholder: React.ReactNode }) {
const { answer, placeholder } = props;
function TransitionAnswerBody(props: {
query: string;
answer: AskAnswerResult;
placeholder: React.ReactNode;
}) {
const { query, answer, placeholder } = props;
const [display, setDisplay] = React.useState<AskAnswerResult | null>(null);
const [_isPending, startTransition] = React.useTransition();
@@ -127,21 +136,25 @@ function TransitionAnswerBody(props: { answer: AskAnswerResult; placeholder: Rea
return display ? (
<div className={tcls('w-full')}>
<AnswerBody answer={display} />
<AnswerBody query={query} answer={display} />
</div>
) : (
<>{placeholder}</>
);
}
function AnswerBody(props: { answer: AskAnswerResult }) {
const { answer } = props;
function AnswerBody(props: { query: string; answer: AskAnswerResult }) {
const { query, answer } = props;
const language = useLanguage();
return (
<>
<div data-testid="search-ask-answer" className="text-tint-strong">
{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 ? (
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />
) : null}
@@ -162,7 +175,7 @@ function AnswerFollowupQuestions(props: { followupQuestions: string[] }) {
const getSearchLinkProps = useSearchLink();
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) => (
<Link
key={question}
@@ -27,8 +27,6 @@ interface SearchContainerProps {
*/
export function SearchContainer(props: SearchContainerProps) {
const { spaceTitle, isMultiVariants, aiMode, className } = props;
const withAI =
aiMode === CustomizationAIMode.Search || aiMode === CustomizationAIMode.Assistant;
const [state, setSearchState] = useSearch();
const searchAsk = useSearchAskState();
@@ -122,7 +120,7 @@ export function SearchContainer(props: SearchContainerProps) {
<Popover
content={
// 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}>
{isMultiVariants && !state?.ask ? (
<SearchScopeToggle spaceTitle={spaceTitle} />
@@ -174,7 +172,7 @@ export function SearchContainer(props: SearchContainerProps) {
onFocus={onOpen}
onChange={onChange}
onKeyDown={onKeyDown}
withAI={withAI}
aiMode={aiMode}
isOpen={open}
className={className}
/>
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { CustomizationAIMode } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { Button, variantClasses } from '../primitives';
import { useClassnames } from '../primitives/StyleProvider';
@@ -13,7 +14,7 @@ interface SearchInputProps {
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onFocus: () => void;
value: string;
withAI?: boolean;
aiMode: CustomizationAIMode;
isOpen: boolean;
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>(
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 language = useLanguage();
@@ -59,7 +60,7 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
sizeClasses,
// 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',
'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.
'relative z-30 shrink grow justify-start max-md:absolute max-md:right-0',
isOpen ? 'max-md:w-56' : 'max-md:w-[38px]',
@@ -73,7 +74,7 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
size="medium"
iconOnly
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={() => {
onChange('');
inputRef.current?.focus();
@@ -89,15 +90,17 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
onKeyDown={onKeyDown}
onChange={(event) => onChange(event.target.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}
size={10}
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',
isOpen ? '' : 'max-md:opacity-0'
)}
ref={inputRef}
/>
<Shortcut />
{!isOpen ? <Shortcut /> : null}
</div>
</div>
);
@@ -124,7 +127,7 @@ function Shortcut() {
<div
aria-busy={operatingSystem === null ? 'true' : undefined}
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
? 'motion-safe:animate-fadeIn motion-reduce:opacity-11'
: 'opacity-0'
@@ -3,15 +3,19 @@
import type { CustomizationThemeMode, SiteExternalLinksTarget } from '@gitbook/api';
import { ThemeProvider } from 'next-themes';
import type React from 'react';
import { useClearRouterCache } from '../hooks/useClearRouterCache';
import { LinkSettingsContext } from '../primitives';
export function ClientContexts(props: {
nonce?: string;
forcedTheme: CustomizationThemeMode | undefined;
externalLinksTarget: SiteExternalLinksTarget;
contextId: string | undefined;
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
@@ -9,12 +9,11 @@ import { AdminToolbar } from '@/components/AdminToolbar';
import { CookiesToast } from '@/components/Cookies';
import { LoadIntegrations } from '@/components/Integrations';
import { SpaceLayout } from '@/components/SpaceLayout';
import { buildVersion } from '@/lib/build';
import { isSiteIndexable } from '@/lib/seo';
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 { getResizedImageURL } from '@/lib/images';
import { isSiteIndexable } from '@/lib/seo';
import { ClientContexts } from './ClientContexts';
import { RocketLoaderDetector } from './RocketLoaderDetector';
@@ -50,6 +49,7 @@ export async function SiteLayout(props: {
<NuqsAdapter>
<ClientContexts
nonce={nonce}
contextId={context.contextId}
forcedTheme={
forcedTheme ??
(customization.themes.toggeable ? undefined : customization.themes.default)
@@ -19,7 +19,7 @@ export function SitePageSkeleton() {
'lg:items-start'
)}
>
<div className={tcls('flex-1', 'max-w-3xl', 'mx-auto', 'site-full-width:mx-0')}>
<div className={tcls('flex-1', 'max-w-3xl', 'mx-auto', 'site-width-wide:mx-0')}>
<SkeletonHeading style={tcls('mb-8')} />
<SkeletonParagraph style={tcls('mb-4')} />
</div>
@@ -97,7 +97,7 @@ export function SpaceLayout(props: {
'flex-col',
'lg:flex-row',
CONTAINER_STYLE,
'site-full-width:max-w-full',
'site-width-wide:max-w-full',
// Ensure the footer is display below the viewport even if the content is not enough
withFooter && 'min-h-[calc(100vh-64px)]',
@@ -40,6 +40,10 @@ export function Trademark(props: {
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'rounded-lg',
'straight-corners:rounded-none',
'circular-corners:rounded-2xl',
'before:hidden',
'lg:before:block',
'before:content-[""]',
@@ -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',
'max-w-screen-2xl',
'mx-auto',
// 'site-full-width:max-w-full',
];
/**
@@ -31,6 +31,8 @@ export const variantClasses = {
'hover:text-contrast-primary-solid-hover',
'border-0',
'contrast-more:border-1',
'disabled:bg-tint',
'disabled:text-tint/8',
],
blank: [
'bg-transparent',
@@ -42,6 +44,8 @@ export const variantClasses = {
'hover:scale-1',
'contrast-more:bg-tint-subtle',
'depth-subtle:hover:translate-y-0',
'disabled:text-tint/8',
'disabled:bg-transparent',
],
secondary: [
'bg-tint',
@@ -51,6 +55,8 @@ export const variantClasses = {
'depth-flat:hover:bg-tint-hover',
'hover:text-primary',
'contrast-more:bg-tint-subtle',
'disabled:bg-transparent',
'disabled:text-tint/8',
],
header: [
'bg-tint-base text-tint',
@@ -59,23 +65,23 @@ export const variantClasses = {
'theme-bold:bg-header-link/2',
'theme-bold:text-header-link',
'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:text-header-link',
'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:text-header-link',
'theme-bold:contrast-more:ring-header-link',
'theme-bold:contrast-more:hover:ring-header-link',
'theme-bold:contrast-more:border-header-link',
'theme-bold:contrast-more:hover:border-header-link',
],
};
const activeClasses = {
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',
header: 'bg-header-link/3',
};
@@ -99,13 +105,14 @@ export const Button = React.forwardRef<
children,
active,
trailing,
disabled,
...rest
},
ref
) => {
const sizes = {
default: ['text-base', 'font-semibold', 'px-5', 'py-2', 'circular-corners:px-6'],
medium: ['text-sm', 'px-3.5', 'py-1.5', 'circular-corners:px-4'],
default: ['text-base', 'font-semibold', 'px-5', 'py-2'],
medium: ['text-sm', iconOnly ? 'px-2' : 'px-3.5', 'py-1.5'],
small: ['text-xs', 'py-2', iconOnly ? 'px-2' : 'px-3'],
xsmall: ['text-xs', 'py-1', iconOnly ? 'px-1.5' : 'px-2'],
};
@@ -142,6 +149,7 @@ export const Button = React.forwardRef<
classNames={['ButtonStyles']}
insights={insights}
aria-label={label?.toString()}
aria-pressed={active === undefined ? undefined : active}
target={target}
{...rest}
>
@@ -156,12 +164,38 @@ export const Button = React.forwardRef<
type="button"
className={tcls(buttonOnlyClassNames, domClassName)}
aria-label={label?.toString()}
aria-pressed={active === undefined ? undefined : active}
disabled={disabled}
{...rest}
>
{content}
</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
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
)}
>
@@ -194,7 +194,7 @@ export function DropdownSubMenu(props: { children: React.ReactNode; label: React
collisionPadding={8}
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}
</div>
</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.
*/
@@ -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 (
<NextLink
ref={ref}
href={href}
prefetch={prefetch}
prefetch={_prefetch}
className={tcls(...forwardedClassNames, className)}
{...domProps}
onClick={onClick}
@@ -1,5 +1,9 @@
'use client';
import { tcls } from '@/lib/tailwind';
import * as RadixTooltip from '@radix-ui/react-tooltip';
import { useState } from 'react';
import { useIsMobile } from '../hooks/useIsMobile';
export function Tooltip(props: {
children: React.ReactNode;
@@ -22,10 +26,24 @@ export function Tooltip(props: {
className,
} = props;
const isMobile = useIsMobile();
const [open, setOpen] = useState(false);
return (
<RadixTooltip.Provider delayDuration={300} {...providerProps}>
<RadixTooltip.Root {...rootProps}>
<RadixTooltip.Trigger asChild {...triggerProps}>
<RadixTooltip.Root open={open} onOpenChange={(e) => setOpen(e)} {...rootProps}>
<RadixTooltip.Trigger
asChild
onClick={(event) => {
event.preventDefault();
if (isMobile) {
setOpen(true);
}
setOpen(true);
}}
onPointerDown={(event) => event.preventDefault()}
{...triggerProps}
>
{children}
</RadixTooltip.Trigger>
<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',
className
)}
onPointerDownOutside={(event) => {
event.preventDefault();
if (isMobile) {
setOpen(false);
}
}}
{...contentProps}
>
{label}
@@ -8,7 +8,7 @@ export const ButtonStyles = [
'gap-2',
'rounded-md',
'straight-corners:rounded-none',
'circular-corners:rounded-full',
'circular-corners:rounded-3xl',
'border',
'border-tint',
@@ -33,12 +33,9 @@ export const ButtonStyles = [
'shrink-0',
'truncate',
'disabled:opacity-50',
'disabled:cursor-not-allowed',
'disabled:bg-tint',
'disabled:text-tint/8',
'disabled:shadow-none',
'disabled:hover:scale-100',
'disabled:!translate-y-0',
'disabled:!shadow-none',
] as ClassValue[];
export const CardStyles = [
@@ -1,7 +1,5 @@
import type { GitBookSiteContext } from '@/lib/context';
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) =>
context.customization.ai?.mode === CustomizationAIMode.Assistant &&
(context.site.id === 'site_p4Xo4' || context.site.id === 'site_JOVzv');
context.customization.ai.mode === CustomizationAIMode.Assistant;
@@ -32,6 +32,8 @@ export const de = {
was_this_helpful_negative: 'Nein',
was_this_helpful_thank_you: 'Danke!',
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',
annotation_button_label: 'Kommentar öffnen',
code_copied: 'Kopiert!',
@@ -86,6 +88,7 @@ export const de = {
ai_chat_context_disclaimer: 'KI-Antworten können Fehler enthalten.',
ai_chat_input_placeholder: 'Fragen, suchen oder Aktion ausführen...',
send: 'Senden',
actions: 'Aktionen',
ai_chat_suggested_questions_title: 'Vorgeschlagene Fragen',
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?',
@@ -32,6 +32,8 @@ export const en = {
was_this_helpful_negative: 'No',
was_this_helpful_thank_you: 'Thank you!',
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',
annotation_button_label: 'Open annotation',
code_copied: 'Copied!',
@@ -84,6 +86,7 @@ export const en = {
ai_chat_context_disclaimer: 'AI responses may contain mistakes.',
ai_chat_input_placeholder: 'Ask, search, or take action...',
send: 'Send',
actions: 'Actions',
ai_chat_suggested_questions_title: 'Suggested questions',
ai_chat_suggested_questions_about_this_page: 'What is this page about?',
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_thank_you: '¡Gracias!',
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',
annotation_button_label: 'Abrir anotación',
code_copied: '¡Copiado!',
@@ -88,6 +90,7 @@ export const es: TranslationLanguage = {
ai_chat_context_disclaimer: 'Las respuestas de IA pueden contener errores.',
ai_chat_input_placeholder: 'Pregunta, busca o realiza una acción...',
send: 'Enviar',
actions: 'Acciones',
ai_chat_suggested_questions_title: 'Preguntas sugeridas',
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?',
@@ -34,6 +34,8 @@ export const fr: TranslationLanguage = {
was_this_helpful_negative: 'Non',
was_this_helpful_thank_you: 'Merci!',
was_this_helpful_comment: 'Quelque chose à ajouter?',
was_this_helpful_positive_label: 'Utile',
was_this_helpful_negative_label: 'Pas utile',
submit: 'Soumettre',
annotation_button_label: "Ouvrir l'annotation",
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_input_placeholder: 'Demander, rechercher ou effectuer une action...',
send: 'Envoyer',
actions: 'Actions',
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_read_next: 'Que devrais-je lire ensuite ?',
@@ -34,6 +34,8 @@ export const ja: TranslationLanguage = {
was_this_helpful_negative: 'いいえ',
was_this_helpful_thank_you: 'ありがとうございます!',
was_this_helpful_comment: '何か追加したいことはありますか?',
was_this_helpful_positive_label: '役立つ',
was_this_helpful_negative_label: '役立たない',
submit: '送信',
annotation_button_label: '注釈を開く',
code_copied: 'コピーしました!',
@@ -86,6 +88,7 @@ export const ja: TranslationLanguage = {
ai_chat_context_disclaimer: 'AIの回答には誤りが含まれる場合があります。',
ai_chat_input_placeholder: '質問、検索、またはアクションを実行...',
send: '送信',
actions: 'アクション',
ai_chat_suggested_questions_title: 'おすすめの質問',
ai_chat_suggested_questions_about_this_page: 'このページは何について説明していますか?',
ai_chat_suggested_questions_read_next: '次に何を読むべきですか?',
@@ -34,6 +34,8 @@ export const nl: TranslationLanguage = {
was_this_helpful_negative: 'Nee',
was_this_helpful_thank_you: 'Bedankt!',
was_this_helpful_comment: 'Nog iets toe te voegen?',
was_this_helpful_positive_label: 'Nuttig',
was_this_helpful_negative_label: 'Niet nuttig',
submit: 'Versturen',
annotation_button_label: 'Open annotatie',
code_copied: 'Gekopieerd!',
@@ -86,6 +88,7 @@ export const nl: TranslationLanguage = {
ai_chat_context_disclaimer: 'AI-antwoorden kunnen fouten bevatten.',
ai_chat_input_placeholder: 'Vraag, zoek of voer een actie uit...',
send: 'Versturen',
actions: 'Acties',
ai_chat_suggested_questions_title: 'Voorgestelde vragen',
ai_chat_suggested_questions_about_this_page: 'Waar gaat deze pagina over?',
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_thank_you: 'Takk!',
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',
annotation_button_label: 'Åpne merknad',
code_copied: 'Kopiert!',
@@ -86,6 +88,7 @@ export const no: TranslationLanguage = {
ai_chat_context_disclaimer: 'AI-svar kan inneholde feil.',
ai_chat_input_placeholder: 'Spør, søk eller utfør en handling...',
send: 'Send',
actions: 'Handlinger',
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_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_thank_you: 'Obrigado!',
was_this_helpful_comment: 'Gostaria de adicionar algo?',
was_this_helpful_positive_label: 'Útil',
was_this_helpful_negative_label: 'Não útil',
submit: 'Enviar',
annotation_button_label: 'Abrir anotação',
code_copied: 'Copiado!',
@@ -86,6 +88,7 @@ export const pt_br = {
ai_chat_context_disclaimer: 'Respostas de IA podem conter erros.',
ai_chat_input_placeholder: 'Pergunte, pesquise ou execute uma ação...',
send: 'Enviar',
actions: 'Ações',
ai_chat_suggested_questions_title: 'Perguntas sugeridas',
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?',
@@ -33,6 +33,8 @@ export const zh: TranslationLanguage = {
was_this_helpful_negative: '不',
was_this_helpful_thank_you: '谢谢!',
was_this_helpful_comment: '您有什么想补充的吗?',
was_this_helpful_positive_label: '有帮助',
was_this_helpful_negative_label: '没有帮助',
submit: '提交',
annotation_button_label: '打开批注',
code_copied: '已复制!',
@@ -83,6 +85,7 @@ export const zh: TranslationLanguage = {
ai_chat_context_disclaimer: '人工智能的回答可能包含错误。',
ai_chat_input_placeholder: '询问、搜索或执行操作...',
send: '发送',
actions: '操作',
ai_chat_suggested_questions_title: '建议的问题',
ai_chat_suggested_questions_about_this_page: '这个页面讲的是什么?',
ai_chat_suggested_questions_read_next: '我接下来应该读什么?',
+7
View File
@@ -44,6 +44,7 @@ export type SiteURLData = Pick<
| 'siteSection'
| 'siteBasePath'
| 'basePath'
| 'contextId'
> & {
/**
* Identifier used for image resizing.
@@ -123,6 +124,9 @@ export type GitBookSiteContext = GitBookSpaceContext & {
/** Scripts to load for the site. */
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,
changeRequest: data.changeRequest,
revision: data.revision,
contextId: data.contextId,
});
}
@@ -217,6 +222,7 @@ export async function fetchSiteContextByIds(
shareKey: string | undefined;
changeRequest: string | undefined;
revision: string | undefined;
contextId?: string;
}
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
@@ -314,6 +320,7 @@ export async function fetchSiteContextByIds(
structure: siteStructure,
sections,
scripts,
contextId: ids.contextId,
};
}
-7
View File
@@ -181,9 +181,6 @@ export function extractCacheControl(error: GitBookAPIError) {
*/
export function getExposableError(error: Error): DataFetcherErrorData {
if (error instanceof GitBookAPIError) {
if (error.code >= 500) {
throw error;
}
const cache = extractCacheControl(error);
return {
@@ -194,10 +191,6 @@ export function getExposableError(error: Error): DataFetcherErrorData {
}
if (error instanceof DataFetcherError) {
if (error.code >= 500) {
throw error;
}
return {
code: error.code,
message: error.message,
@@ -48,10 +48,15 @@ export async function resizeImage(
input: string,
options: CloudflareImageOptions & {
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> {
const action = checkIsSizableImageURL(input);
if (action === SizableImageAction.Skip) {
if (action === SizableImageAction.Skip && !options.bypassSkipCheck) {
throw new Error(
'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 { 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 { enrichFilesystem } from './enrich';
import type {
@@ -40,7 +41,7 @@ export async function fetchOpenAPIFilesystem(
assert(resolved.openAPIFilesystem);
return resolved.openAPIFilesystem;
}
return fetchFilesystem(resolved.href);
return fetchFilesystem(resolved.href, context.space.id);
})();
if ('error' in filesystem) {
@@ -54,7 +55,8 @@ export async function fetchOpenAPIFilesystem(
}
const fetchFilesystem = async (
url: string
url: string,
spaceId: string
): Promise<
| Filesystem
| {
@@ -66,6 +68,7 @@ const fetchFilesystem = async (
> => {
'use cache';
try {
cacheTag(getCacheTag({ tag: 'space', space: spaceId }));
return await fetchFilesystemUncached(url);
} catch (error) {
// 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
const response = await fetch(new URL(url), {
...noCacheFetchOptions,
cache: 'no-store',
signal: options?.signal,
});
+17 -1
View File
@@ -1,5 +1,9 @@
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';
@@ -28,11 +32,14 @@ describe('resolveFirstDocument', () => {
pages: [],
layout: {
cover: true,
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
title: true,
description: true,
tableOfContents: true,
outline: true,
pagination: true,
width: RevisionPageLayoutOptionsWidth.Default,
metadata: true,
},
},
],
@@ -75,11 +82,14 @@ describe('resolveFirstDocument', () => {
pages: [],
layout: {
cover: true,
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
title: true,
description: true,
tableOfContents: true,
outline: true,
pagination: true,
width: RevisionPageLayoutOptionsWidth.Default,
metadata: true,
},
},
];
@@ -115,11 +125,14 @@ describe('resolvePagePath', () => {
pages: [],
layout: {
cover: true,
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
title: true,
description: true,
tableOfContents: true,
outline: true,
pagination: true,
width: RevisionPageLayoutOptionsWidth.Default,
metadata: true,
},
},
];
@@ -185,11 +198,14 @@ describe('resolvePagePath', () => {
pages: [],
layout: {
cover: true,
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
title: true,
description: true,
tableOfContents: true,
outline: true,
pagination: true,
width: RevisionPageLayoutOptionsWidth.Default,
metadata: true,
},
},
],
+4 -4
View File
@@ -49,10 +49,6 @@ export function defaultCustomization(): api.SiteCustomizationSettings {
feedback: {
enabled: false,
},
// TODO: remove aiSearch once the cache has been fully updated (after 11/07/2025)
aiSearch: {
enabled: true,
},
ai: {
mode: api.CustomizationAIMode.None,
},
@@ -68,6 +64,10 @@ export function defaultCustomization(): api.SiteCustomizationSettings {
pagination: {
enabled: true,
},
pageActions: {
externalAI: true,
markdown: true,
},
trademark: {
enabled: true,
},
+7
View File
@@ -249,6 +249,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
shareKey: siteURLData.shareKey,
apiToken: siteURLData.apiToken,
imagesContextId: imagesContextId,
contextId: siteURLData.contextId,
};
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-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);
};
+17 -12
View File
@@ -252,6 +252,8 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
height: 630,
fonts: fonts.length ? fonts : undefined,
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': [
getCacheTag({
tag: 'site',
@@ -366,19 +368,22 @@ const SUPPORTED_IMAGE_TYPES = [
async function fetchImage(url: string, options?: ResizeImageOptions) {
// Skip early some images to avoid fetching them
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.
// as @vercel/og can sometimes fail on some JPEG images.
const response =
checkIsSizableImageURL(url) !== SizableImageAction.Resize
? await fetch(url)
: await resizeImage(url, {
...options,
format: 'png',
});
let response: Response;
if (
UNSUPPORTED_IMAGE_EXTENSIONS.includes(getExtension(parsedURL.pathname).toLowerCase()) ||
checkIsSizableImageURL(url) === SizableImageAction.Resize
) {
// We use the image resizer to normalize the image format to PNG.
// as @vercel/og can sometimes fail on some JPEG images, and will fail on avif and webp images.
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
const contentType = response.headers.get('content-type');
+12 -3
View File
@@ -298,10 +298,16 @@ const config: Config = {
},
animation: {
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-slow': 'scaleIn 500ms ease both',
scaleOut: 'scaleOut 200ms ease both',
'scaleOut-slow': 'scaleOut 500ms ease both',
fadeIn: 'fadeIn 200ms ease both',
'fadeIn-slow': 'fadeIn 500ms 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',
enterFromRight: 'enterFromRight 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) &'
);
// 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 = {
// Sidebar styles
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.
*/
addVariant('site-full-width', 'body:has(.site-full-width) &');
addVariant('site-default-width', 'body:has(.site-default-width) &');
addVariant('page-full-width', 'body:has(.page-full-width) &');
addVariant('site-width-wide', 'body:has(.site-width-wide) &');
addVariant('site-width-default', 'body:has(.site-width-default) &');
addVariant('page-width-wide', 'body:has(.page-width-wide) &');
/**
* Variant when the page is configured to hide the table of content.
+3 -4
View File
@@ -58,12 +58,11 @@ export const Icon = React.forwardRef(function Icon(
{...rest}
style={{
maskImage: `url(${url})`,
//@ts-ignore // For compatibility with older browsers
'-webkit-mask-image': `url(${url})`,
WebkitMaskImage: `url(${url})`,
maskRepeat: 'no-repeat',
'-webkit-mask-repeat': 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskPosition: 'center',
'-webkit-mask-position': 'center',
WebkitMaskPosition: 'center',
...(size ? { width: size, height: size } : {}),
...rest.style,
}}
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/openapi-parser
## 2.2.2
### Patch Changes
- b5ad0ce: Fix OpenAPI v2 parsing
## 2.2.1
### Patch Changes
+1 -1
View File
@@ -9,7 +9,7 @@
"default": "./dist/index.js"
}
},
"version": "2.2.1",
"version": "2.2.2",
"sideEffects": false,
"dependencies": {
"@scalar/openapi-parser": "^0.18.0",
+11 -1
View File
@@ -3,11 +3,12 @@ import { OpenAPIParseError } from './error';
import { parseOpenAPI } from './parse';
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>`;
describe('#parseOpenAPI', () => {
it('parses an OpenAPI document', async () => {
it('parses a recursive OpenAPI document', async () => {
const schema = await parseOpenAPI({
value: spec,
rootURL: null,
@@ -16,6 +17,15 @@ describe('#parseOpenAPI', () => {
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 () => {
expect.assertions(1);
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 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.
if (!result.valid) {
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;
}
+97 -31
View File
@@ -18,41 +18,107 @@ export function ElementButton(
const clientContext = useContentKitClientContext();
const [loading, setLoading] = React.useState(false);
const [confirm, setConfirm] = React.useState<boolean>(false);
// TODO:
// - confirm dialog
const wrapLoading = React.useCallback(async <T,>(fn: Promise<T> | (() => Promise<T>)) => {
setLoading(true);
try {
return fn instanceof Promise ? await fn : await fn();
} finally {
setLoading(false);
}
}, []);
return (
<button
title={element.tooltip}
className={classNames(
'contentkit-button',
`contentkit-button-style-${element.style ?? 'secondary'}`
)}
onClick={(event) => {
if (element.disabled || loading) {
return;
}
<>
<button
type="button"
title={element.tooltip}
className={classNames(
'contentkit-button',
`contentkit-button-style-${element.style ?? 'secondary'}`
)}
onClick={(event) => {
if (element.disabled || loading) {
return;
}
event.stopPropagation();
event.stopPropagation();
event.preventDefault();
setLoading(true);
clientContext.dispatchAction(element.onPress).finally(() => {
setLoading(false);
});
}}
>
{loading ? (
<Icon icon="spinner" className="contentkit-button-loading" />
) : (
<>
{icon}
{element.label ? (
<span className="contentkit-button-label">{element.label}</span>
) : null}
{trailingIcon}
</>
)}
</button>
if (element.confirm && !confirm) {
setConfirm(true);
return;
}
wrapLoading(async () => await clientContext.dispatchAction(element.onPress));
}}
>
{loading ? (
<Icon icon="spinner" className="contentkit-button-loading" />
) : (
<>
{icon}
{element.label ? (
<span className="contentkit-button-label">{element.label}</span>
) : null}
{trailingIcon}
</>
)}
</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
## 1.3.4
### Patch Changes
- Updated dependencies [b5ad0ce]
- @gitbook/openapi-parser@2.2.2
## 1.3.3
### Patch Changes
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js"
}
},
"version": "1.3.3",
"version": "1.3.4",
"sideEffects": false,
"dependencies": {
"@gitbook/openapi-parser": "workspace:*",