mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72f5ce3985 | |||
| 04c80262e2 | |||
| 56d1b8fe2d | |||
| 054a1634ab | |||
| 6ff63f2db6 | |||
| b5ad0ce1e5 | |||
| 2ba7e54c81 | |||
| 0a858e74ad | |||
| b0c534fc2c | |||
| 250c194a02 | |||
| 5b10cde936 | |||
| 4aeb81bfa5 | |||
| 955cebf944 | |||
| 7c951ef437 | |||
| 68f0dbcc35 | |||
| 39c4f76aea | |||
| 2fe58e8cbb | |||
| 765141a4d8 | |||
| eb1bd3aa01 | |||
| b7284b0223 | |||
| 5bbd254742 | |||
| 611e28626a |
@@ -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
@@ -34,7 +34,7 @@
|
||||
"workspaces": {
|
||||
"packages": ["packages/*"],
|
||||
"catalog": {
|
||||
"@gitbook/api": "^0.128.0"
|
||||
"@gitbook/api": "^0.129.0"
|
||||
}
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,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: '我接下来应该读什么?',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @gitbook/openapi-parser
|
||||
|
||||
## 2.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b5ad0ce: Fix OpenAPI v2 parsing
|
||||
|
||||
## 2.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -28,5 +28,5 @@ export async function convertOpenAPIV2ToOpenAPIV3(
|
||||
}
|
||||
})();
|
||||
|
||||
return parseOpenAPIV3({ ...input, rootURL, value: upgradeResult.specification });
|
||||
return parseOpenAPIV3({ ...input, rootURL, value: upgradeResult.specification, trust: true });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.4",
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"@gitbook/openapi-parser": "workspace:*",
|
||||
|
||||
Reference in New Issue
Block a user