Compare commits

...

31 Commits

Author SHA1 Message Date
Zeno Kapitein 3a37591bd4 Fix double display on no-toc 2025-08-14 20:09:19 +02:00
Zeno Kapitein b9b5887b5f Mobile considerations 2025-08-14 20:02:17 +02:00
Zeno Kapitein 6ecdfc695a Moves language picker to bottom right 2025-08-14 19:40:31 +02:00
Zeno Kapitein a9d011aa2e Move variant selector to sidebar & support translations 2025-08-14 19:21:13 +02:00
conico974 c0e9c49679 Optimize cache retrieval performance and reduce costs on 2C (#3542)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-14 09:14:40 +02:00
conico974 afc4bd4415 Improve image resizing logic in fetchImage function (#3553)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-13 15:35:14 +02:00
Zeno Kapitein bcfa8d8b19 Improve vertical alignment of site items and fix floating page aside (#3560) 2025-08-13 14:36:07 +02:00
Zeno Kapitein 43766d6519 Fix custom logo not rendering on the published site (#3557) 2025-08-13 14:29:49 +02:00
Greg Bergé 42c17f5c74 Improve OpenAPI parsing errors (#3555) 2025-08-13 13:38:14 +02:00
Greg Bergé d655d3eece Support "objectivec" as alias for Objective C syntax (#3556) 2025-08-13 13:37:06 +02:00
conico974 3500de95e8 Never follow redirect in the middleware (#3552)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-13 12:58:43 +02:00
Taran Vohra 7f03b94949 Fix an issue where recommended questions werent using the current siteSpace context (#3559) 2025-08-13 11:06:05 +05:30
Zeno Kapitein f0cf060191 Disable AI Search tests for now (#3561) 2025-08-12 22:12:51 +02:00
Zeno Kapitein 5a137e7b14 Always show placeholder while generating (#3554) 2025-08-12 15:43:29 +02:00
conico974 28f7fbaa8a Fix image format handling and enhance PageCoverImage component (#3527)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-08 18:30:00 +02:00
Nolann B. ff96bb5787 Support new coverDefinitionDark for cards & image type (#3547) 2025-08-08 18:08:11 +02:00
conico974 87841124e0 Add HashContext and HashProvider for managing URL hash state (#3541)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-08 17:01:21 +02:00
conico974 a650b58276 feat: add tracing to server actions (#3543)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-08 16:10:06 +02:00
Zeno Kapitein 388b20d44f Clear AI chat stream properly (#3544) 2025-08-07 19:07:25 +02:00
Claire Chabas ea7e94fc51 Fix search bar layout shit caused by the ToC (#3545) 2025-08-07 15:19:20 +02:00
Zeno Kapitein d30bcbabdb Improve Button and ButtonGroup styling (#3540) 2025-08-06 16:10:55 +02:00
Zeno Kapitein fb858a1941 Tweaks to AIChatButton and AIChatInput (#3539) 2025-08-06 16:10:05 +02:00
Samy Pessé cc2e615b9a Emit a <link rel="alternate"> for the markdown version of a page (#3528) 2025-08-06 08:55:02 +02:00
Samy Pessé a862cde6b4 Use new API to fetch the reusable content document (#3536) 2025-08-06 08:54:43 +02:00
Zeno Kapitein e2b7bec162 Add linewrap to AI actions dropdown (#3534) 2025-08-05 12:17:00 +00:00
Zeno Kapitein c1b577ecc9 Updates to RU translation (#3532) 2025-08-05 14:10:45 +02:00
Zeno Kapitein ba7ec14f76 Fix z-index of search highlight (#3533) 2025-08-05 14:08:46 +02:00
Zeno Kapitein 9f0117723c Add russian translation (#3531) 2025-08-05 12:52:30 +02:00
conico974 f72b6a20db Validate 'next-action' header format in server action requests (#3523)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-05 11:31:43 +02:00
conico974 2f798209bb Bump OpenNext to 1.6.4 (#3520)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-05 11:18:56 +02:00
conico974 eec8f16424 Increase timeout for search answer visibility check (#3530)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-05 11:18:12 +02:00
78 changed files with 1389 additions and 851 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Improve `Button` and `ButtonGroup` styling
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Support "objectivec" as alias for Objective C syntax
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Tweaks to AIChatButton and AIChatInput
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Improve vertical alignment of site items and fix floating page aside
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix search bar layout shift caused by ToC
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Move variant selector to sidebar & support translations
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Emit a <link rel="alternate"> for the markdown version of a page
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Support new coverDefinitionDark for cards & image type
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Clear AI chat properly
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix bug in search highlight in GBO
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/openapi-parser": patch
"gitbook": patch
---
Improve OpenAPI parsing errors
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix Custom logo not rendering on the published site
+11 -11
View File
@@ -48,7 +48,7 @@
},
"packages/gitbook": {
"name": "gitbook",
"version": "0.14.1",
"version": "0.15.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/cache-tags": "workspace:*",
@@ -60,7 +60,7 @@
"@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*",
"@opennextjs/cloudflare": "^1.6.2",
"@opennextjs/cloudflare": "^1.6.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-navigation-menu": "^1.2.3",
@@ -146,7 +146,7 @@
},
"packages/icons": {
"name": "@gitbook/icons",
"version": "0.2.1",
"version": "0.2.2",
"bin": {
"gitbook-icons": "./bin/gitbook-icons.js",
},
@@ -166,7 +166,7 @@
},
"packages/openapi-parser": {
"name": "@gitbook/openapi-parser",
"version": "2.2.2",
"version": "3.0.0",
"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.2",
"version": "0.7.3",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:*",
@@ -196,7 +196,7 @@
},
"packages/react-math": {
"name": "@gitbook/react-math",
"version": "0.6.0",
"version": "0.6.1",
"bin": {
"gitbook-math": "./bin/gitbook-math.js",
},
@@ -213,7 +213,7 @@
},
"packages/react-openapi": {
"name": "@gitbook/react-openapi",
"version": "1.3.4",
"version": "1.3.5",
"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.130.0",
"@gitbook/api": "^0.134.0",
},
"packages": {
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="],
@@ -611,7 +611,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.130.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-nfAPfTISUou+hJPZLrBsuUSSaHEeWB6eZ4adapWAagmaeeA3CpfopDROXcqGJPBHYPfvfLZIGdgyYhyYC3Objw=="],
"@gitbook/api": ["@gitbook/api@0.134.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-WMdLsA0ZOTbOyoloevPs0qa/VR2xmfp+YB6T/a2o8fkFUv5fMXxDVfCAcIxB2q9NCmkriMSCohWKmxLfz44s6w=="],
"@gitbook/cache-tags": ["@gitbook/cache-tags@workspace:packages/cache-tags"],
@@ -789,9 +789,9 @@
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@opennextjs/aws": ["@opennextjs/aws@3.7.1", "", { "dependencies": { "@ast-grep/napi": "^0.35.0", "@aws-sdk/client-cloudfront": "3.398.0", "@aws-sdk/client-dynamodb": "^3.398.0", "@aws-sdk/client-lambda": "^3.398.0", "@aws-sdk/client-s3": "^3.398.0", "@aws-sdk/client-sqs": "^3.398.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "5.0.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.0.0", "yaml": "^2.7.0" }, "bin": { "open-next": "dist/index.js" } }, "sha512-ryV5cQucSJDU0TF3+Jzqn9g0/+zhUZgjNopSnchPvH0SxSAbKkNaNH7SlnWBhykjusqHaFzQ8dfEjcjAq7TJSQ=="],
"@opennextjs/aws": ["@opennextjs/aws@3.7.4", "", { "dependencies": { "@ast-grep/napi": "^0.35.0", "@aws-sdk/client-cloudfront": "3.398.0", "@aws-sdk/client-dynamodb": "^3.398.0", "@aws-sdk/client-lambda": "^3.398.0", "@aws-sdk/client-s3": "^3.398.0", "@aws-sdk/client-sqs": "^3.398.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "5.0.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.0.0", "yaml": "^2.7.0" }, "bin": { "open-next": "dist/index.js" } }, "sha512-s50dmKrgQ62GliffoI/hGGQVb3q/7ZN5VRa4jJw0ZsEGLfk25XuAulO/ySCYeE7/A23KoAYuhafnKr/h+vxOeQ=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.6.2", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.7.1", "cloudflare": "^4.4.1", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "wrangler": "^4.24.4" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-N96OTbp1970NwLP6dNkAzBDrxjthLQ3VXHcjKl8edqNbJ/iT75H60ZYHU/ZZ7gSZQeJluPvPb1n0ZPQ08EooCQ=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.6.4", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.7.4", "cloudflare": "^4.4.1", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "wrangler": "^4.24.4" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-vGEHr1+Dli4BSrs+CM+b2bnXoUdY/GUFiLfdH28UTRHggLMG7cL49M39LtzIVR5D8eqzZ38PDB2CZMn1LJ2gSw=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
+1 -1
View File
@@ -34,7 +34,7 @@
"workspaces": {
"packages": ["packages/*"],
"catalog": {
"@gitbook/api": "^0.130.0"
"@gitbook/api": "^0.134.0"
}
},
"patchedDependencies": {
+13 -84
View File
@@ -97,53 +97,9 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('search-results')).toBeVisible();
},
},
{
name: 'Search - AI Mode: Search - Complete flow',
url: getCustomizationURL({
ai: {
mode: CustomizationAIMode.Search,
},
}),
screenshot: false,
run: async (page) => {
const searchInput = page.getByTestId('search-input');
// Focus search input, expecting recommended questions
await searchInput.focus();
await expect(page.getByTestId('search-results')).toBeVisible();
const recommendedQuestions = await page
.getByTestId('search-recommended-question')
.all();
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
// Fill search input, expecting AI search option
await searchInput.fill('What is gitbook?');
await expect(page.getByTestId('search-results')).toBeVisible();
const aiSearchResult = page.getByTestId('search-ask-question');
await expect(aiSearchResult).toBeVisible();
await aiSearchResult.click();
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
timeout: 15_000,
});
},
},
{
name: 'Search - AI Mode: Search - URL query (Initial)',
url: `${getCustomizationURL({
ai: {
mode: CustomizationAIMode.Search,
},
})}&q=`,
screenshot: false,
run: async (page) => {
await expect(page.getByTestId('search-input')).toBeFocused();
await expect(page.getByTestId('search-results')).toBeVisible();
const recommendedQuestions = await page
.getByTestId('search-recommended-question')
.all();
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
},
},
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
// - Search - AI Mode: Search - Complete flow
// - Search - AI Mode: Search - URL query (Initial)
{
name: 'Search - AI Mode: Search - URL query (Results)',
url: `${getCustomizationURL({
@@ -158,37 +114,9 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('search-results')).toBeVisible();
},
},
{
name: 'Ask - AI Mode: Search - URL query (Ask initial)',
url: `${getCustomizationURL({
ai: {
mode: CustomizationAIMode.Search,
},
})}&ask=`,
screenshot: false,
run: async (page) => {
await expect(page.getByTestId('search-input')).toBeFocused();
await expect(page.getByTestId('search-results')).toBeVisible();
const recommendedQuestions = await page
.getByTestId('search-recommended-question')
.all();
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
},
},
{
name: 'Ask - AI Mode: Search - URL query (Ask results)',
url: `${getCustomizationURL({
ai: {
mode: CustomizationAIMode.Search,
},
})}&ask=What+is+GitBook%3F`,
screenshot: false,
run: async (page) => {
await expect(page.getByTestId('search-input')).toBeFocused();
await expect(page.getByTestId('search-input')).toHaveValue('What is GitBook?');
await expect(page.getByTestId('search-ask-answer')).toBeVisible();
},
},
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
// - Ask - AI Mode: Search - URL query (Ask initial)
// - Ask - AI Mode: Search - URL query (Ask results)
{
name: 'Ask - AI Mode: Assistant - Complete flow',
url: getCustomizationURL({
@@ -202,11 +130,12 @@ const searchTestCases: Test[] = [
// Focus search input, expecting recommended questions
await searchInput.focus();
await expect(page.getByTestId('search-results')).toBeVisible();
const recommendedQuestions = await page
.getByTestId('search-recommended-question')
.all();
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
// TODO: Re-enable this part of the test when we have fixed the AI Search timing out
// await expect(page.getByTestId('search-results')).toBeVisible();
// const recommendedQuestions = await page
// .getByTestId('search-recommended-question')
// .all();
// await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
// Fill search input, expecting AI search option
await searchInput.fill('What is gitbook?');
@@ -225,7 +154,7 @@ const searchTestCases: Test[] = [
}),
screenshot: false,
run: async (page) => {
await page.keyboard.press('ControlOrMeta+J');
await page.keyboard.press('ControlOrMeta+I');
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
},
@@ -23,6 +23,7 @@ export default class extends WorkerEntrypoint {
`gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"`
);
return this.env.DEFAULT_WORKER?.fetch(reqOrResp, {
redirect: 'manual',
cf: {
cacheEverything: false,
},
@@ -33,6 +34,9 @@ export default class extends WorkerEntrypoint {
modifiedUrl.hostname = this.env.PREVIEW_HOSTNAME;
const nextRequest = new Request(modifiedUrl, reqOrResp);
return fetch(nextRequest, {
// We never want to follow the redirects here.
// Redirects are supposed to happen from the client.
redirect: 'manual',
cf: {
cacheEverything: false,
},
@@ -25,7 +25,8 @@
// When deployed locally, we don't have access to the tag cache here,
// we should just bypass the cache to go to the server directly
"SHOULD_BYPASS_CACHE": "true",
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
"GITBOOK_URL": "http://localhost:8771"
},
"r2_buckets": [
{
@@ -48,7 +48,13 @@ class GitbookIncrementalCache implements IncrementalCache {
const result = (await localCacheEntry.json()) as WithLastModified<
CacheValue<CacheType>
>;
return this.returnNullOn404(result);
return this.returnNullOn404({
...result,
// Because we use tag cache and also invalidate them every time,
// if we get a cache hit, we don't need to check the tag cache as we already know it's not been revalidated
// this should improve performance even further, and reduce costs
shouldBypassTagCache: true,
});
}
const r2Object = await r2.get(cacheKey);
+1 -1
View File
@@ -13,7 +13,7 @@
"@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*",
"@opennextjs/cloudflare": "^1.6.2",
"@opennextjs/cloudflare": "^1.6.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-navigation-menu": "^1.2.3",
@@ -1,6 +1,7 @@
'use server';
import type { GitBookBaseContext } from '@/lib/context';
import { fetchServerActionSiteContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
import {
type AIMessage,
AIMessageRole,
@@ -19,97 +20,100 @@ export async function streamRenderAIMessage(
rawStream: AsyncIterable<AIStreamResponse>,
options?: RenderAIMessageOptions
) {
const message: AIMessage = {
id: '',
role: AIMessageRole.Assistant,
steps: [],
};
const updateProcessingMessageStep = (
stepIndex: number,
callback: (step: AIMessageStep) => void
) => {
if (stepIndex > message.steps.length) {
throw new Error(
`Step index out of bounds ${stepIndex} (${message.steps.length} steps)`
);
}
if (message.steps[stepIndex]) {
message.steps = [...message.steps];
message.steps[stepIndex] = { ...message.steps[stepIndex] };
callback(message.steps[stepIndex]);
} else {
message.steps = [
...message.steps,
{
content: {
object: 'document',
data: {},
nodes: [],
},
},
];
callback(message.steps[stepIndex]);
}
};
// Fetch the full-context in the background to avoid blocking the stream.
const promiseContext = fetchServerActionSiteContext(baseContext);
return parseResponse<{
content: React.ReactNode;
event: AIStreamResponse;
}>(rawStream, async (event) => {
switch (event.type) {
/**
* The agent is processing a tool call in a new message.
*/
case 'response_tool_call': {
updateProcessingMessageStep(event.stepIndex, (step) => {
step.toolCalls ??= [];
step.toolCalls.push(event.toolCall);
});
break;
}
/**
* The agent is writing the content of a new message.
*/
case 'response_reasoning':
case 'response_document': {
updateProcessingMessageStep(event.stepIndex, (step) => {
const container = event.type === 'response_reasoning' ? 'reasoning' : 'content';
step[container] ??= {
object: 'document',
data: {},
nodes: [],
};
step[container] = {
...step[container],
nodes: [...step[container].nodes],
};
if (event.operation === 'insert') {
step[container].nodes.push(...event.blocks);
} else {
step[container].nodes.splice(
-event.blocks.length,
event.blocks.length,
...event.blocks
);
}
});
break;
}
}
return {
event,
content: (
<AIMessageView message={message} context={await promiseContext} {...options} />
),
return traceErrorOnly('AI.streamRenderAIMessage', async () => {
const message: AIMessage = {
id: '',
role: AIMessageRole.Assistant,
steps: [],
};
const updateProcessingMessageStep = (
stepIndex: number,
callback: (step: AIMessageStep) => void
) => {
if (stepIndex > message.steps.length) {
throw new Error(
`Step index out of bounds ${stepIndex} (${message.steps.length} steps)`
);
}
if (message.steps[stepIndex]) {
message.steps = [...message.steps];
message.steps[stepIndex] = { ...message.steps[stepIndex] };
callback(message.steps[stepIndex]);
} else {
message.steps = [
...message.steps,
{
content: {
object: 'document',
data: {},
nodes: [],
},
},
];
callback(message.steps[stepIndex]);
}
};
// Fetch the full-context in the background to avoid blocking the stream.
const promiseContext = fetchServerActionSiteContext(baseContext);
return parseResponse<{
content: React.ReactNode;
event: AIStreamResponse;
}>(rawStream, async (event) => {
switch (event.type) {
/**
* The agent is processing a tool call in a new message.
*/
case 'response_tool_call': {
updateProcessingMessageStep(event.stepIndex, (step) => {
step.toolCalls ??= [];
step.toolCalls.push(event.toolCall);
});
break;
}
/**
* The agent is writing the content of a new message.
*/
case 'response_reasoning':
case 'response_document': {
updateProcessingMessageStep(event.stepIndex, (step) => {
const container =
event.type === 'response_reasoning' ? 'reasoning' : 'content';
step[container] ??= {
object: 'document',
data: {},
nodes: [],
};
step[container] = {
...step[container],
nodes: [...step[container].nodes],
};
if (event.operation === 'insert') {
step[container].nodes.push(...event.blocks);
} else {
step[container].nodes.splice(
-event.blocks.length,
event.blocks.length,
...event.blocks
);
}
});
break;
}
}
return {
event,
content: (
<AIMessageView message={message} context={await promiseContext} {...options} />
),
};
});
});
}
@@ -1,6 +1,7 @@
'use server';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
import { type AIMessageContext, AIMessageRole, AIModel } from '@gitbook/api';
import { streamRenderAIMessage } from './api';
import type { RenderAIMessageOptions } from './types';
@@ -19,25 +20,31 @@ export async function* streamAIChatResponse({
previousResponseId?: string;
options?: RenderAIMessageOptions;
}) {
const context = await getServerActionBaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
const context = await getServerActionBaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const api = await context.dataFetcher.api();
const rawStream = api.orgs.streamAiResponseInSite(siteURLData.organization, siteURLData.site, {
mode: 'assistant',
input: [
const api = await context.dataFetcher.api();
const rawStream = api.orgs.streamAiResponseInSite(
siteURLData.organization,
siteURLData.site,
{
role: AIMessageRole.User,
content: message,
context: messageContext,
},
],
output: { type: 'document' },
model: AIModel.ReasoningLow,
previousResponseId,
});
mode: 'assistant',
input: [
{
role: AIMessageRole.User,
content: message,
context: messageContext,
},
],
output: { type: 'document' },
model: AIModel.ReasoningLow,
previousResponseId,
}
);
const { stream } = await streamRenderAIMessage(context, rawStream, options);
return await streamRenderAIMessage(context, rawStream, options);
});
for await (const output of stream) {
yield output;
@@ -1,6 +1,7 @@
'use server';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
import { streamRenderAIMessage } from './api';
import type { RenderAIMessageOptions } from './types';
@@ -14,16 +15,18 @@ export async function* streamAIResponseById({
responseId: string;
options?: RenderAIMessageOptions;
}) {
const context = await getServerActionBaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const { stream } = await traceErrorOnly('AI.streamAIResponseById', async () => {
const context = await getServerActionBaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const api = await context.dataFetcher.api();
const rawStream = api.orgs.streamExistingAiResponseInSite(
siteURLData.organization,
siteURLData.site,
responseId
);
const { stream } = await streamRenderAIMessage(context, rawStream, options);
const api = await context.dataFetcher.api();
const rawStream = api.orgs.streamExistingAiResponseInSite(
siteURLData.organization,
siteURLData.site,
responseId
);
return await streamRenderAIMessage(context, rawStream, options);
});
for await (const output of stream) {
yield output;
@@ -187,6 +187,8 @@ export function useAIChatController(): AIChatController {
for await (const data of stream) {
if (!data) continue;
if (globalState.getState().state.query !== input.message) break; // Chat was cleared, stop processing the stream
const event = data.event;
switch (event.type) {
@@ -231,13 +231,15 @@ function AIActionWrapper(props: {
}
size="xsmall"
variant="secondary"
label={shortLabel || label}
label={label ?? shortLabel}
className="bg-tint-base text-sm"
onClick={onClick}
href={href}
target={href ? '_blank' : undefined}
disabled={disabled || loading}
/>
>
{shortLabel}
</Button>
);
}
@@ -251,13 +253,13 @@ function AIActionWrapper(props: {
>
<div className="flex size-5 items-center justify-center text-tint">
{loading ? (
<Icon icon="spinner-third" className="size-4 animate-spin" />
<Icon icon="spinner-third" className="size-4 shrink-0 animate-spin" />
) : icon ? (
typeof icon === 'string' ? (
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Regular}
className="size-4 fill-transparent stroke-current"
className="size-4 shrink-0 fill-transparent stroke-current"
/>
) : (
icon
@@ -268,9 +270,9 @@ function AIActionWrapper(props: {
<div className="flex flex-1 flex-col gap-0.5">
<span className="flex items-center gap-2 text-tint-strong">
<span className="truncate font-medium text-sm">{label}</span>
{href ? <Icon icon="arrow-up-right" className="size-3" /> : null}
{href ? <Icon icon="arrow-up-right" className="size-3 shrink-0" /> : null}
</span>
{description && <span className="truncate text-tint text-xs">{description}</span>}
{description && <span className="text-tint text-xs">{description}</span>}
</div>
</DropdownMenuItem>
);
@@ -8,6 +8,7 @@ import {
} from '@/components/AIActions/AIActions';
import { Button, ButtonGroup } from '@/components/primitives/Button';
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
import { tString, useLanguage } from '@/intl/client';
import type { SiteCustomizationSettings } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
@@ -25,6 +26,7 @@ interface AIActionsDropdownProps {
*/
export function AIActionsDropdown(props: AIActionsDropdownProps) {
const ref = useRef<HTMLDivElement>(null);
const language = useLanguage();
return (
<ButtonGroup ref={ref}>
@@ -41,6 +43,7 @@ export function AIActionsDropdown(props: AIActionsDropdownProps) {
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
/>
}
label={tString(language, 'more')}
iconOnly
size="xsmall"
variant="secondary"
@@ -9,7 +9,7 @@ import {
type AIChatState,
useAIChatController,
useAIChatState,
} from '../AI/useAIChat';
} from '../AI';
import { useNow } from '../hooks';
import { Button } from '../primitives';
import { DropdownMenu, DropdownMenuItem } from '../primitives/DropdownMenu';
@@ -24,7 +24,7 @@ export function AIChat(props: { trademark: boolean }) {
const chatController = useAIChatController();
useHotkeys(
'mod+j',
'mod+i',
(e) => {
e.preventDefault();
chatController.open();
@@ -2,7 +2,7 @@
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
import { useAIChatController } from '../AI';
import { Button } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
import { getAIChatName } from './AIChat';
@@ -11,33 +11,30 @@ import { AIChatIcon } from './AIChatIcon';
/**
* Button to open/close the AI chat.
*/
export function AIChatButton(props: { trademark: boolean }) {
const { trademark } = props;
export function AIChatButton(props: { trademark: boolean; withLabel: boolean }) {
const { trademark, withLabel } = props;
const chatController = useAIChatController();
const chat = useAIChatState();
const language = useLanguage();
return (
<Button
icon={<AIChatIcon trademark={trademark} />}
data-testid="ai-chat-button"
iconOnly
size="default"
iconOnly={!withLabel}
size="medium"
variant="header"
className={tcls('h-9 px-2.5')}
label={
<div className="flex items-center gap-2">
{t(language, 'ai_chat_ask', getAIChatName(trademark))}
<KeyboardShortcut keys={['mod', 'j']} className="border-tint-11 text-tint-1" />
<KeyboardShortcut keys={['mod', 'i']} className="border-tint-11 text-tint-1" />
</div>
}
onClick={() => {
if (chat.opened) {
chatController.close();
} else {
chatController.open();
}
chatController.open();
}}
/>
>
{withLabel ? <span className="max-md:hidden">{t(language, 'ask')}</span> : null}
</Button>
);
}
@@ -45,7 +45,7 @@ export function AIChatInput(props: {
}, [disabled, loading]);
useHotkeys(
'mod+j',
'mod+i',
(e) => {
e.preventDefault();
inputRef.current?.focus();
@@ -54,7 +54,7 @@ export function AIChatInput(props: {
);
return (
<div className="relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base">
<div className="depth-subtle:has-[textarea:focus]:-translate-y-px relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 depth-subtle:shadow-sm shadow-tint/6 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:shadow-primary-subtle has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base dark:shadow-tint-1">
<textarea
ref={inputRef}
disabled={disabled || loading}
@@ -99,10 +99,12 @@ export function AIChatInput(props: {
}
}}
/>
<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">
{!disabled ? (
<div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
<KeyboardShortcut keys={['mod', 'i']} className="bg-tint-base" />
</div>
) : null}
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 py-2">
<Tooltip
label={
<div className="flex flex-col gap-3 p-2">
@@ -126,11 +128,11 @@ export function AIChatInput(props: {
}
arrow
>
<div className="flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2 py-1 text-tint/7 text-xs transition-all hover:bg-tint">
<div className="flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2.5 py-1.5 text-tint/7 text-xs transition-all hover:bg-tint">
<span className="-ml-1 circular-corners:rounded-2xl rounded-corners:rounded-sm bg-tint-11/7 px-1 py-0.5 font-mono font-semibold text-[0.65rem] text-contrast-tint-11 leading-none">
{t(language, 'ai_chat_context_badge')}
</span>{' '}
<span>{t(language, 'ai_chat_context_title')}</span>
<span className="leading-none">{t(language, 'ai_chat_context_title')}</span>
</div>
</Tooltip>
<Button
@@ -1,7 +1,7 @@
import { tcls } from '@/lib/tailwind';
import { AIMessageRole } from '@gitbook/api';
import type React from 'react';
import type { AIChatController, AIChatState } from '../AI/useAIChat';
import type { AIChatController, AIChatState } from '../AI';
import { AIResponseFeedback } from './AIResponseFeedback';
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
@@ -44,7 +44,7 @@ export function AIChatMessages(props: {
{message.content ? message.content : null}
{isLastMessage && chat.loading ? (
<div className="flex w-full animate-fade-in-slow flex-wrap gap-2 group-has-[.has-content]/message:hidden">
<div className="flex w-full animate-fade-in-slow flex-wrap gap-2">
{Array.from({ length: 7 }).map((_, index) => (
<div
key={index}
@@ -1,5 +1,5 @@
import { tString, useLanguage } from '@/intl/client';
import type { AIChatController } from '../AI/useAIChat';
import type { AIChatController } from '../AI';
import { Button } from '../primitives';
export default function AIChatSuggestedQuestions(props: { chatController: AIChatController }) {
@@ -17,7 +17,6 @@ export default function AIChatSuggestedQuestions(props: { chatController: AIChat
{DEFAULT_SUGGESTED_QUESTIONS.map((question, index) => (
<Button
key={question}
label={question}
variant="secondary"
size="medium"
className="max-w-full animate-[present_500ms_both] whitespace-normal"
@@ -4,6 +4,7 @@ import type { SiteInsightsAd, SiteInsightsAdPlacement } from '@gitbook/api';
import { headers } from 'next/headers';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
import { AdClassicRendering } from './AdClassicRendering';
import { AdCoverRendering } from './AdCoverRendering';
import { AdPixels } from './AdPixels';
@@ -40,40 +41,42 @@ interface FetchPlaceholderAdOptions {
* and properly access user-agent and IP.
*/
export async function renderAd(options: FetchAdOptions) {
const [context, result] = await Promise.all([
getServerActionBaseContext(),
options.source === 'live' ? fetchAd(options) : getPlaceholderAd(),
]);
return traceErrorOnly('Ads.renderAd', async () => {
const [context, result] = await Promise.all([
getServerActionBaseContext(),
options.source === 'live' ? fetchAd(options) : getPlaceholderAd(),
]);
const mode = options.source === 'live' ? options.mode : 'classic';
if (!result || !result.ad.description || !result.ad.statlink) {
return null;
}
const mode = options.source === 'live' ? options.mode : 'classic';
if (!result || !result.ad.description || !result.ad.statlink) {
return null;
}
const { ad } = result;
const { ad } = result;
const insightsAd: SiteInsightsAd | null =
options.source === 'live'
? {
placement: options.placement,
zoneId: options.zoneId,
domain: 'company' in ad ? ad.company : '',
}
: null;
const insightsAd: SiteInsightsAd | null =
options.source === 'live'
? {
placement: options.placement,
zoneId: options.zoneId,
domain: 'company' in ad ? ad.company : '',
}
: null;
return {
children: (
<>
{mode === 'classic' || !('callToAction' in ad) ? (
<AdClassicRendering ad={ad} insightsAd={insightsAd} context={context} />
) : (
<AdCoverRendering ad={ad} insightsAd={insightsAd} context={context} />
)}
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
</>
),
insightsAd,
};
return {
children: (
<>
{mode === 'classic' || !('callToAction' in ad) ? (
<AdClassicRendering ad={ad} insightsAd={insightsAd} context={context} />
) : (
<AdCoverRendering ad={ad} insightsAd={insightsAd} context={context} />
)}
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
</>
),
insightsAd,
};
});
}
async function fetchAd({
@@ -2,6 +2,7 @@
import { getDataOrNull } from '@/lib/data';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
/**
* Return true if a change-request has been updated.
@@ -11,15 +12,17 @@ export async function hasContentBeenUpdated(props: {
changeRequestId: string;
revisionId: string;
}) {
const context = await getServerActionBaseContext();
const changeRequest = await getDataOrNull(
context.dataFetcher.getChangeRequest({
spaceId: props.spaceId,
changeRequestId: props.changeRequestId,
})
);
if (!changeRequest) {
return false;
}
return changeRequest.revision !== props.revisionId;
return traceErrorOnly('AutoRefreshContent.hasContentBeenUpdated', async () => {
const context = await getServerActionBaseContext();
const changeRequest = await getDataOrNull(
context.dataFetcher.getChangeRequest({
spaceId: props.spaceId,
changeRequestId: props.changeRequestId,
})
);
if (!changeRequest) {
return false;
}
return changeRequest.revision !== props.revisionId;
});
}
@@ -43,7 +43,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
</div>
<CopyCodeButton
codeId={id}
style="z-2 mt-2 mr-2 self-start justify-self-end rounded-md bg-transparent p-1 text-tint text-xs leading-none opacity-0 ring-1 ring-tint backdrop-blur-md transition-opacity duration-75 [grid-area:2/1] hover:ring-tint-hover group-hover/codeblock:opacity-11"
style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11"
/>
<pre
className={tcls(
@@ -2,6 +2,7 @@
import React from 'react';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -41,9 +42,14 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
};
return (
<button onClick={onClick} className={tcls(style, 'print:hidden')}>
<Button
size="xsmall"
variant="secondary"
onClick={onClick}
className={tcls(style, 'translate-y-0!', 'print:hidden')}
>
{t(language, copied ? 'code_copied' : 'code_copy')}
</button>
</Button>
);
}
@@ -136,6 +136,9 @@ const syntaxAliases: Record<string, BundledLanguage> = {
// "Parser" language does not exist in Shiki, but it's used in GitBook
// The closest language is "Blade"
parser: 'blade',
// From GitBook App we receive "objectivec" instead of "objective-c"
objectivec: 'objective-c',
};
function checkIsBundledLanguage(lang: string): lang is BundledLanguage {
@@ -1,6 +1,7 @@
'use server';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
import type { RenderIntegrationUI } from '@gitbook/api';
import { ContentKitOutput } from '@gitbook/react-contentkit';
import { contentKitServerContext } from './contentkit';
@@ -19,20 +20,22 @@ export async function renderIntegrationUi({
};
request: RenderIntegrationUI;
}) {
const serverAction = await getServerActionBaseContext();
const output = await fetchSafeIntegrationUI(serverAction, {
integrationName: renderContext.integrationName,
request,
});
return traceErrorOnly('DocumentView.renderIntegrationUi', async () => {
const serverAction = await getServerActionBaseContext();
const output = await fetchSafeIntegrationUI(serverAction, {
integrationName: renderContext.integrationName,
request,
});
if (output.error) {
return {
error: output.error.message,
};
}
if (output.error) {
return {
error: output.error.message,
children: <ContentKitOutput output={output.data} context={contentKitServerContext} />,
output: output.data,
};
}
return {
children: <ContentKitOutput output={output.data} context={contentKitServerContext} />,
output: output.data,
};
});
}
@@ -147,7 +147,7 @@
}
.openapi-column-preview-body {
@apply flex flex-col gap-4 sticky top-4 site-header:top-20 site-header:xl:max-2xl:top-32 site-header-sections:top-32 site-header-sections:xl:max-2xl:top-44 print-mode:static;
@apply flex flex-col gap-4 sticky top-[calc(var(--toc-top-offset)+5rem)] print-mode:static;
}
.openapi-column-preview pre {
@@ -26,18 +26,15 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
}
const { reusableContent } = resolved;
if (
!reusableContent ||
!('document' in reusableContent.revisionReusableContent) ||
!reusableContent.revisionReusableContent.document
) {
if (!reusableContent) {
return null;
}
const document = await getDataOrNull(
dataFetcher.getDocument({
dataFetcher.getRevisionReusableContentDocument({
spaceId: reusableContent.context.space.id,
documentId: reusableContent.revisionReusableContent.document,
revisionId: reusableContent.context.revisionId,
reusableContentId: reusableContent.revisionReusableContent.id,
})
);
@@ -1,18 +1,16 @@
import { LinkBox, LinkOverlay } from '@/components/primitives';
import { Image } from '@/components/utils';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import {
type ContentRef,
type DocumentTableViewCards,
SiteInsightsLinkPosition,
} from '@gitbook/api';
import { LinkBox, LinkOverlay } from '@/components/primitives';
import { Image } from '@/components/utils';
import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { RecordColumnValue } from './RecordColumnValue';
import type { TableRecordKV, TableViewProps } from './Table';
import { RecordCardStyles } from './styles';
import { getRecordValue } from './utils';
import { getRecordCardCovers } from './utils';
export async function RecordCard(
props: TableViewProps<DocumentTableViewCards> & {
@@ -21,25 +19,21 @@ export async function RecordCard(
) {
const { view, record, context, block, isOffscreen } = props;
const coverFile = view.coverDefinition
? getRecordValue<string[]>(record[1], view.coverDefinition)?.[0]
: null;
const { dark, light } = getRecordCardCovers(record[1], view);
const targetRef = view.targetDefinition
? (record[1].values[view.targetDefinition] as ContentRef)
: null;
const [cover, target] = await Promise.all([
coverFile && context.contentContext
? resolveContentRef({ kind: 'file', file: coverFile }, context.contentContext)
: null,
const [lightCover, darkCover, target] = await Promise.all([
light && context.contentContext ? resolveContentRef(light, context.contentContext) : null,
dark && context.contentContext ? resolveContentRef(dark, context.contentContext) : null,
targetRef && context.contentContext
? resolveContentRef(targetRef, context.contentContext)
: null,
]);
const coverIsSquareOrPortrait =
cover?.file?.dimensions &&
cover.file?.dimensions?.width / cover.file?.dimensions?.height <= 1;
const darkCoverIsSquareOrPortrait = isSquareOrPortrait(darkCover);
const lightCoverIsSquareOrPortrait = isSquareOrPortrait(lightCover);
const body = (
<div
@@ -63,23 +57,32 @@ export async function RecordCard(
// On mobile, check if we can display the cover responsively or not:
// - If the file has a landscape aspect ratio, we display it normally
// - If the file is square or portrait, we display it left with 40% of the card width
coverIsSquareOrPortrait
lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
? [
'grid-cols-[40%__1fr]',
'min-[432px]:grid-cols-none',
'min-[432px]:grid-rows-[auto_1fr]',
]
lightCoverIsSquareOrPortrait
? 'grid-cols-[40%__1fr] min-[432px]:grid-cols-none min-[432px]:grid-rows-[auto_1fr]'
: '',
darkCoverIsSquareOrPortrait
? 'dark:grid-cols-[40%__1fr] dark:min-[432px]:grid-cols-none dark:min-[432px]:grid-rows-[auto_1fr]'
: '',
].filter(Boolean)
: 'grid-rows-[auto_1fr]'
)}
>
{cover ? (
{lightCover ? (
<Image
alt="Cover"
sources={{
light: {
src: cover.href,
size: cover.file?.dimensions,
src: lightCover.href,
size: lightCover.file?.dimensions,
},
dark: darkCover
? {
src: darkCover.href,
size: darkCover.file?.dimensions,
}
: null,
}}
sizes={[
{
@@ -92,8 +95,15 @@ export async function RecordCard(
'w-full',
'h-full',
'object-cover',
coverIsSquareOrPortrait
? ['min-[432px]:h-auto', 'min-[432px]:aspect-video']
lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
? [
lightCoverIsSquareOrPortrait
? 'min-[432px]:aspect-video min-[432px]:h-auto'
: '',
darkCoverIsSquareOrPortrait
? 'dark:min-[432px]:aspect-video dark:min-[432px]:h-auto'
: '',
].filter(Boolean)
: ['h-auto', 'aspect-video']
)}
priority={isOffscreen ? 'lazy' : 'high'}
@@ -167,3 +177,16 @@ export async function RecordCard(
return <div className={tcls(RecordCardStyles)}>{body}</div>;
}
/**
* Check if a file is square or portrait.
*/
function isSquareOrPortrait(contentRef: ResolvedContentRef | null) {
const file = contentRef?.file;
if (!file || !file.dimensions) {
return false;
}
return file.dimensions?.width / file.dimensions?.height <= 1;
}
@@ -174,7 +174,6 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
<StyledLink
key={index}
href={ref.href}
target="_blank"
className="flex flex-row items-center gap-2"
insights={
ref.file
@@ -329,6 +328,50 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
</Tag>
);
}
case 'image': {
const image = context.contentContext
? await resolveContentRef(value as ContentRef, context.contentContext)
: null;
if (!image) {
return null;
}
return (
<Tag className={tcls('text-base')} aria-labelledby={ariaLabelledBy}>
<StyledLink
href={image.href}
className="flex flex-row items-center gap-2"
insights={
image.file
? {
type: 'link_click',
link: {
target: value as ContentRef,
position: SiteInsightsLinkPosition.Content,
},
}
: undefined
}
>
<Image
style={['max-h-lh', 'h-lh']}
alt={image.text}
sizes={[{ width: 24 }]}
resize={context.contentContext?.imageResizer}
sources={{
light: {
src: image.href,
size: image.file?.dimensions,
},
}}
priority="lazy"
/>
{image.text}
</StyledLink>
</Tag>
);
}
default:
assertNever(definition);
}
@@ -1,4 +1,11 @@
import type { ContentRef, DocumentTableDefinition, DocumentTableRecord } from '@gitbook/api';
import type {
ContentRef,
ContentRefFile,
ContentRefURL,
DocumentTableDefinition,
DocumentTableRecord,
DocumentTableViewCards,
} from '@gitbook/api';
import assertNever from 'assert-never';
/**
@@ -12,6 +19,57 @@ export function getRecordValue<T extends number | string | boolean | string[] |
return record.values[definitionId];
}
/**
* Get the covers for a record card.
* Returns both the light and dark covers.
* The light cover is a string or a content ref (image or files column type).
* The dark cover is a content ref (image column type).
*/
export function getRecordCardCovers(
record: DocumentTableRecord,
view: DocumentTableViewCards
): { [key in 'light' | 'dark']: ContentRefFile | ContentRefURL | null } {
return {
light: (() => {
if (!view.coverDefinition) {
return null;
}
const value = getRecordValue(record, view.coverDefinition) as
| ContentRefFile
| ContentRefURL
| string[];
if (Array.isArray(value)) {
if (value.length === 0) {
return null;
}
if (typeof value[0] === 'string') {
return { kind: 'file', file: value[0] };
}
}
return value as ContentRefFile | ContentRefURL;
})(),
dark: (() => {
if (!view.coverDefinitionDark) {
return null;
}
const value = getRecordValue(record, view.coverDefinitionDark) as
| ContentRefFile
| ContentRefURL;
if (!value) {
return null;
}
return value;
})(),
};
}
/**
* Get the text alignment for a column.
*/
@@ -17,9 +17,10 @@ import { SpacesDropdown } from './SpacesDropdown';
export function Header(props: {
context: GitBookSiteContext;
withTopHeader?: boolean;
withVariants?: 'generic' | 'translations';
search?: React.ReactNode;
}) {
const { context, withTopHeader, search } = props;
const { context, withTopHeader, withVariants, search } = props;
const { siteSpace, siteSpaces, sections, customization } = context;
return (
@@ -110,7 +111,6 @@ export function Header(props: {
'lg:max-w-lg',
'lg:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem)
'xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem)
'lg:page-no-toc:ml-[max(calc((100%-18rem-48rem-18rem-3rem)/2),0rem)]',
'md:mr-auto',
'order-last',
'md:order-[unset]',
@@ -144,7 +144,7 @@ export function Header(props: {
</div>
</div>
{sections || siteSpaces.length > 1 ? (
{sections || withVariants ? (
<div className="scroll-nojump">
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div
@@ -165,10 +165,13 @@ export function Header(props: {
'page-default-width:2xl:px-[calc((100%-1536px+4rem)/2)]'
)}
>
{siteSpaces.length > 1 && (
{withVariants ? (
<div
id="variants"
className="my-2 mr-5 grow border-tint border-r pr-5 *:grow only:mr-0 only:border-none only:pr-0 sm:max-w-64"
className={tcls(
'my-2 mr-5 page-no-toc:flex hidden grow border-tint border-r pr-5 *:grow only:mr-0 only:border-none only:pr-0 sm:max-w-64',
withVariants === 'translations' ? 'xl:hidden!' : ''
)}
>
<SpacesDropdown
context={context}
@@ -177,14 +180,17 @@ export function Header(props: {
className="w-full grow py-1"
/>
</div>
)}
{sections &&
(sections.list.some((s) => s.object === 'site-section-group') || // If there's even a single group, show the tabs
sections.list.length > 1) && ( // Otherwise, show the tabs if there's more than one section
<SiteSectionTabs
sections={encodeClientSiteSections(context, sections)}
/>
)}
) : null}
{sections
? (sections.list.some(
(s) => s.object === 'site-section-group'
) || // If there's even a single group, show the tabs
sections.list.length > 1) && ( // Otherwise, show the tabs if there's more than one section
<SiteSectionTabs
sections={encodeClientSiteSections(context, sections)}
/>
)
: null}
</div>
</div>
</div>
@@ -55,9 +55,10 @@ export async function HeaderLogo(props: HeaderLogoProps) {
'lg:max-w-64',
'lg:site-header-none:page-no-toc:max-w-56',
'max-h-10',
'lg:max-h-12',
'h-full',
'w-auto'
'w-full',
'object-contain',
'object-left'
)}
/>
) : (
@@ -44,18 +44,18 @@ export function SpacesDropdown(props: {
'hover:text-tint-strong',
'data-[state=open]:text-tint-strong',
'ring-1',
'ring-tint-subtle',
'hover:ring-tint-hover',
'data-[state=open]:ring-tint-hover',
'border',
'border-tint-subtle',
'hover:border-tint-hover',
'data-[state=open]:border-tint-hover',
'contrast-more:bg-tint-base',
'contrast-more:ring-1',
'contrast-more:hover:ring-2',
'contrast-more:data-[state=open]:ring-2',
'contrast-more:ring-tint',
'contrast-more:hover:ring-tint-hover',
'contrast-more:data-[state=open]:ring-tint-hover',
'contrast-more:border',
'contrast-more:hover:border-2',
'contrast-more:data-[state=open]:border-2',
'contrast-more:border-tint',
'contrast-more:hover:border-tint-hover',
'contrast-more:data-[state=open]:border-tint-hover',
className
)}
@@ -15,6 +15,7 @@ import { getDocumentSections } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import { Ad } from '../Ads';
import { SpacesDropdown } from '../Header/SpacesDropdown';
import { getPDFURLSearchParams } from '../PDF';
import { PageFeedbackForm } from '../PageFeedback';
import { ThemeToggler } from '../ThemeToggler';
@@ -32,33 +33,27 @@ export function PageAside(props: {
withPageFeedback: boolean;
}) {
const { page, document, withPageFeedback, context } = props;
const { customization, site, space } = context;
const language = getSpaceLanguage(customization);
const pdfHref = context.linker.toPathInSpace(
`~gitbook/pdf?${getPDFURLSearchParams({
page: page.id,
only: true,
limit: 100,
}).toString()}`
);
return (
<aside
className={tcls(
'group/aside',
'hidden',
'pt-8',
'xl:flex',
'xl:max-3xl:chat-open:hidden',
'xl:max-3xl:chat-open:opacity-0',
'max-w-56',
// Animate the width of the aside when the chat is open
'xl:max-3xl:*:chat-open:w-56',
'xl:max-3xl:chat-open:max-w-0',
'xl:max-3xl:chat-open:ml-0',
'motion-safe:xl:transition-all motion-safe:xl:duration-300',
'motion-safe:xl:transition-[width,max-width,margin,opacity,display] motion-safe:xl:duration-300',
'motion-safe:transition-discrete',
'flex-col',
'basis-56',
'xl:ml-12',
'grow-0',
@@ -68,6 +63,7 @@ export function PageAside(props: {
'text-tint',
'contrast-more:text-tint-strong',
'sticky',
// Without header
'lg:top-0',
'lg:max-h-screen',
@@ -80,196 +76,237 @@ export function PageAside(props: {
'lg:site-header-sections:top-27',
'lg:site-header-sections:max-h-[calc(100vh-6.75rem)]',
// Client-side dynamic positioning (CSS vars applied by script)
'lg:[html[style*="--toc-top-offset"]_&]:top-(--toc-top-offset)!',
'lg:[html[style*="--toc-height"]_&]:max-h-(--toc-height)!',
// When in api page mode, we display it as an overlay on non-large resolutions
'xl:max-2xl:page-api-block:z-10',
'xl:max-2xl:page-api-block:fixed',
'xl:max-2xl:page-api-block:right-8',
'xl:max-2xl:page-api-block:w-56',
'xl:max-2xl:page-api-block:bg-tint/9',
'xl:max-2xl:contrast-more:page-api-block:bg-tint',
'xl:max-2xl:page-api-block:backdrop-blur-lg',
'xl:max-2xl:page-api-block:border',
'xl:max-2xl:page-api-block:border-tint',
'xl:max-2xl:page-api-block:hover:shadow-lg',
'xl:max-2xl:page-api-block:hover:shadow-tint-12/1',
'xl:max-2xl:dark:page-api-block:hover:shadow-tint-1/1',
'xl:max-2xl:page-api-block:rounded-md',
'xl:max-2xl:page-api-block:h-auto',
'xl:max-2xl:page-api-block:my-9',
'page-api-block:px-2',
'page-api-block:py-1.5'
'xl:max-2xl:page-api-block:w-60',
'xl:max-2xl:page-api-block:max-w-60',
'xl:max-2xl:page-api-block:pb-8',
'xl:max-2xl:page-api-block:pt-10',
'xl:max-2xl:[body:has(.openapi-block):has(.page-has-ancestors)_&]:pt-6.5'
)}
>
{page.layout.outline ? (
<>
<div
className={tcls(
'hidden',
'xl:max-2xl:page-api-block:flex',
'text-xs',
'tracking-wide',
'font-semibold',
'uppercase',
'flex-row',
'items-center',
'gap-2'
)}
>
<Icon icon="block-quote" className={tcls('size-3')} />
{t(language, 'on_this_page')}
<Icon
icon="chevron-down"
className={tcls(
'size-3',
'opacity-6',
'ml-auto',
'xl:max-2xl:page-api-block:group-hover/aside:hidden'
)}
/>
</div>
<div
className={tcls(
'overflow-y-auto',
'overflow-x-visible',
'flex',
'flex-col',
'shrink',
'pb-12',
'sticky',
'lg:top:0',
'lg:site-header:top-16',
'lg:site-header-sections:top-27',
'gap-6',
'pt-10',
'xl:max-2xl:page-api-block:py-0',
// Hide it for api page, until hovered
'xl:max-2xl:page-api-block:hidden',
'xl:max-2xl:page-api-block:group-hover/aside:flex'
)}
>
<div
className={tcls(
'flex flex-col',
'overflow-hidden',
'w-full',
'xl:max-2xl:page-api-block:rounded-md',
'xl:max-2xl:page-api-block:border',
'xl:max-2xl:page-api-block:border-tint',
'xl:max-2xl:page-api-block:bg-tint/9',
'xl:max-2xl:page-api-block:backdrop-blur-lg',
'xl:max-2xl:contrast-more:page-api-block:bg-tint',
'xl:max-2xl:page-api-block:hover:shadow-lg',
'xl:max-2xl:page-api-block:hover:shadow-tint-12/1',
'xl:max-2xl:dark:page-api-block:hover:shadow-tint-1/1',
'xl:max-2xl:page-api-block:not-hover:*:hidden'
)}
>
<PageAsideHeader context={context} />
{page.layout.outline ? (
<div>
{document ? (
<React.Suspense fallback={null}>
<PageAsideSections document={document} context={context} />
</React.Suspense>
) : null}
<div
className={tcls(
'flex',
'flex-col',
'gap-3',
'sidebar-list-default:px-3',
'border-t',
'first:border-none',
'border-tint-subtle',
'py-6',
'first:pt-0',
'xl:max-2xl:page-api-block:px-3',
'empty:hidden'
)}
>
{withPageFeedback ? (
<React.Suspense fallback={null}>
<PageFeedbackForm pageId={page.id} />
</React.Suspense>
) : null}
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
<div>
<a
href={urlJoin(space.gitSync.url, page.git.path)}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon
icon={
space.gitSync.installationProvider === 'gitlab'
? 'gitlab'
: 'github'
}
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'edit_on_git', getGitSyncName(space))}
</a>
</div>
) : null}
{customization.pdf.enabled ? (
<div>
<a
href={pdfHref}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon
icon="file-pdf"
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'pdf_download')}
</a>
</div>
) : null}
</div>
</div>
</>
) : null}
<div
className={tcls(
'sticky bottom-0 z-10 mt-auto flex flex-col bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 xl:max-2xl:page-api-block:hidden xl:max-2xl:page-api-block:pb-0 xl:max-2xl:page-api-block:group-hover/aside:flex [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'xl:max-2xl:page-api-block:bg-transparent'
)}
>
{/* Mode Switcher */}
{customization.themes.toggeable ? (
<div className="mt-4 flex items-center justify-end">
<React.Suspense fallback={null}>
<ThemeToggler />
</React.Suspense>
<PageAsideActions
page={page}
withPageFeedback={withPageFeedback}
context={context}
/>
</div>
) : null}
<Ad
zoneId={
site?.ads && site.ads.status === SiteAdsStatus.Live ? site.ads.zoneId : null
}
placement={SiteInsightsAdPlacement.Aside}
spaceId={space.id}
siteAdsStatus={site?.ads?.status ? site.ads.status : undefined}
ignore={process.env.NODE_ENV !== 'production'}
style={site?.ads ? 'mt-4' : undefined}
/>
<PageAsideFooter context={context} />
</div>
</aside>
);
}
function PageAsideHeader(props: { context: GitBookSiteContext }) {
const { context } = props;
const language = getSpaceLanguage(context.customization);
return (
<div
className={tcls(
'hidden',
'xl:max-2xl:page-api-block:flex!',
'text-xs',
'tracking-wide',
'font-semibold',
'uppercase',
'px-2',
'py-1.5',
'flex-row',
'items-center',
'gap-2'
)}
>
<Icon icon="block-quote" className={tcls('size-3')} />
{t(language, 'on_this_page')}
<Icon icon="chevron-down" className={tcls('size-3', 'opacity-6', 'ml-auto')} />
</div>
);
}
async function PageAsideSections(props: { document: JSONDocument; context: GitBookSiteContext }) {
const { document, context } = props;
const sections = await getDocumentSections(context, document);
return sections.length > 1 ? <ScrollSectionsList sections={sections} /> : null;
return sections.length > 1 ? (
<div className="-mt-8 overflow-y-auto border-tint pt-8 pb-5 empty:hidden xl:max-2xl:page-api-block:mt-0 xl:max-2xl:border-t xl:max-2xl:page-api-block:p-2">
<ScrollSectionsList sections={sections} />
</div>
) : null;
}
function PageAsideActions(props: {
withPageFeedback: boolean;
context: GitBookSiteContext;
page: RevisionPageDocument;
}) {
const { page, withPageFeedback, context } = props;
const { customization, space } = context;
const language = getSpaceLanguage(customization);
const pdfHref = context.linker.toPathInSpace(
`~gitbook/pdf?${getPDFURLSearchParams({
page: page.id,
only: true,
limit: 100,
}).toString()}`
);
return (
<div
className={tcls(
'flex',
'flex-col',
'gap-3',
'sidebar-list-default:px-3',
'border-t',
'first:border-none',
'border-tint-subtle',
'py-5',
'first:pt-0',
'xl:max-2xl:page-api-block:px-5',
'empty:hidden'
)}
>
{withPageFeedback ? (
<React.Suspense fallback={null}>
<PageFeedbackForm pageId={page.id} />
</React.Suspense>
) : null}
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
<div>
<a
href={urlJoin(space.gitSync.url, page.git.path)}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon
icon={
space.gitSync.installationProvider === 'gitlab'
? 'gitlab'
: 'github'
}
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'edit_on_git', getGitSyncName(space))}
</a>
</div>
) : null}
{customization.pdf.enabled ? (
<div>
<a
href={pdfHref}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon icon="file-pdf" className={tcls('size-4', 'mr-1.5')} />
{t(language, 'pdf_download')}
</a>
</div>
) : null}
</div>
);
}
async function PageAsideFooter(props: { context: GitBookSiteContext }) {
const { context } = props;
const { customization, site, space } = context;
return (
<div
className={tcls(
'sticky bottom-0 z-10 mt-auto flex flex-col',
'bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'xl:max-2xl:page-api-block:border-t xl:max-2xl:page-api-block:p-2',
'py-4'
)}
>
{/* Mode Switcher */}
{customization.themes.toggeable ? (
<div className="flex items-center justify-end">
<React.Suspense fallback={null}>
<ThemeToggler />
</React.Suspense>
</div>
) : null}
<Ad
zoneId={
site?.ads && site.ads.status === SiteAdsStatus.Live ? site.ads.zoneId : null
}
placement={SiteInsightsAdPlacement.Aside}
spaceId={space.id}
siteAdsStatus={site?.ads?.status ? site.ads.status : undefined}
ignore={process.env.NODE_ENV !== 'production'}
style={site?.ads ? 'mt-4' : undefined}
/>
{context.siteSpaces.some(
(space) =>
space.space.language &&
space.space.language !== context.siteSpace.space.language
) ? (
<SpacesDropdown
context={context}
siteSpace={context.siteSpace}
siteSpaces={context.siteSpaces}
className="mt-2 w-full grow py-2"
/>
) : null}
</div>
);
}
function getGitSyncName(space: Space): string {
@@ -40,7 +40,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
});
return (
<ul className={tcls('sidebar-list-line:border-l', 'border-tint-subtle')}>
<ul className="flex flex-col border-tint-subtle sidebar-list-line:border-l">
{sections.map((section) => (
<motion.li
key={section.id}
@@ -50,6 +50,8 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
'relative',
'h-fit',
'mt-2',
'first:mt-0',
'mb-0.5',
section.depth > 1 && ['ml-3', 'my-0', 'sidebar-list-line:ml-0']
)}
>
@@ -50,7 +50,7 @@ export function PageBody(props: {
<main
className={tcls(
'relative min-w-0 flex-1',
'mx-auto max-w-screen-2xl py-11',
'mx-auto max-w-screen-2xl py-8',
// Allow words to break if they are too long.
'break-anywhere',
pageWidthWide ? 'page-width-wide 2xl:px-8' : 'page-width-default',
@@ -43,31 +43,30 @@ export async function PageCover(props: {
const getImage = async (resolved: ResolvedContentRef | null, returnNull = false) => {
if (!resolved && returnNull) return;
const [attrs, size] = await Promise.all([
getImageAttributes({
sizes,
source: resolved
? {
src: resolved.href,
size: resolved.file?.dimensions ?? null,
}
: {
src: defaultPageCover.src,
size: {
width: defaultPageCover.width,
height: defaultPageCover.height,
},
// If we don't have a size for the image, we want to calculate it so that we can use srcSet
const size =
resolved?.file?.dimensions ??
(await context.imageResizer?.getImageSize(resolved?.href || defaultPageCover.src, {}));
const attrs = await getImageAttributes({
sizes,
source: resolved
? {
src: resolved.href,
size: size ?? null,
}
: {
src: defaultPageCover.src,
size: {
width: defaultPageCover.width,
height: defaultPageCover.height,
},
quality: 100,
resize: context.imageResizer ?? false,
}),
context.imageResizer
?.getImageSize(resolved?.href || defaultPageCover.src, {})
.then((size) => size ?? undefined),
]);
},
quality: 100,
resize: context.imageResizer ?? false,
});
return {
...attrs,
size,
size: size ?? undefined,
};
};
@@ -44,6 +44,8 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
<div className="h-full w-full overflow-hidden" ref={containerRef}>
<img
src={imgs.light.src}
srcSet={imgs.light.srcSet}
sizes={imgs.light.sizes}
fetchPriority="high"
alt="Page cover"
className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
@@ -55,6 +57,8 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
{imgs.dark && (
<img
src={imgs.dark.src}
srcSet={imgs.dark.srcSet}
sizes={imgs.dark.sizes}
fetchPriority="low"
alt="Page cover"
className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')}
@@ -28,6 +28,8 @@ export async function PageHeader(props: {
externalAI: true,
};
const hasAncestors = ancestors.length > 0;
return (
<header
className={tcls(
@@ -37,7 +39,8 @@ export async function PageHeader(props: {
'mb-6',
'space-y-3',
'page-api-block:ml-0',
'page-api-block:max-w-full'
'page-api-block:max-w-full',
hasAncestors ? 'page-has-ancestors' : 'page-no-ancestors'
)}
>
{page.layout.tableOfContents &&
@@ -45,8 +48,8 @@ export async function PageHeader(props: {
(withAIChat || pageActions.markdown || pageActions.externalAI) ? (
<div
className={tcls(
'float-right mb-2 ml-4 xl:max-2xl:page-api-block:mr-58',
ancestors.length > 0 ? '-mt-2' : '-mt-3 xs:mt-2'
'float-right ml-4 xl:max-2xl:page-api-block:mr-62',
hasAncestors ? '-my-1.5' : '-mt-3 xs:mt-2'
)}
>
<AIActionsDropdown
@@ -57,7 +60,7 @@ export async function PageHeader(props: {
/>
</div>
) : null}
{ancestors.length > 0 && (
{hasAncestors && (
<nav aria-label="Breadcrumb">
<ol className={tcls('flex', 'flex-wrap', 'items-center', 'gap-2', 'text-tint')}>
{ancestors.map((breadcrumb, index) => {
@@ -8,7 +8,7 @@ import { t, tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { useTrackEvent } from '../Insights';
import { Button } from '../primitives';
import { Button, ButtonGroup } from '../primitives';
const MAX_COMMENT_LENGTH = 512;
@@ -61,31 +61,29 @@ export function PageFeedbackForm(props: {
<div className={tcls('flex flex-col gap-3 text-sm', className)}>
<div className="flex flex-wrap items-center gap-2">
<p>{t(languages, 'was_this_helpful')}</p>
<div className="rounded-full border border-tint-subtle bg-tint-base contrast-more:border-tint-12">
<div className="flex">
<RatingButton
rating={PageFeedbackRating.Good}
label={tString(languages, 'was_this_helpful_positive')}
onClick={() => onSubmitRating(PageFeedbackRating.Good)}
active={rating === PageFeedbackRating.Good}
disabled={rating !== undefined}
/>
<RatingButton
rating={PageFeedbackRating.Ok}
label={tString(languages, 'was_this_helpful_neutral')}
onClick={() => onSubmitRating(PageFeedbackRating.Ok)}
active={rating === PageFeedbackRating.Ok}
disabled={rating !== undefined}
/>
<RatingButton
rating={PageFeedbackRating.Bad}
label={tString(languages, 'was_this_helpful_negative')}
onClick={() => onSubmitRating(PageFeedbackRating.Bad)}
active={rating === PageFeedbackRating.Bad}
disabled={rating !== undefined}
/>
</div>
</div>
<ButtonGroup>
<RatingButton
rating={PageFeedbackRating.Good}
label={tString(languages, 'was_this_helpful_positive')}
onClick={() => onSubmitRating(PageFeedbackRating.Good)}
active={rating === PageFeedbackRating.Good}
disabled={rating !== undefined}
/>
<RatingButton
rating={PageFeedbackRating.Ok}
label={tString(languages, 'was_this_helpful_neutral')}
onClick={() => onSubmitRating(PageFeedbackRating.Ok)}
active={rating === PageFeedbackRating.Ok}
disabled={rating !== undefined}
/>
<RatingButton
rating={PageFeedbackRating.Bad}
label={tString(languages, 'was_this_helpful_negative')}
onClick={() => onSubmitRating(PageFeedbackRating.Bad)}
active={rating === PageFeedbackRating.Bad}
disabled={rating !== undefined}
/>
</ButtonGroup>
</div>
{rating ? (
<div className="flex flex-col gap-2">
@@ -148,9 +146,9 @@ function RatingButton(
}[rating] ?? null;
return (
<button
<Button
className={tcls(
'p-2 first:rounded-l-full first:pl-2.5 last:rounded-r-full last:pr-2.5 hover:bg-primary-hover hover:text-primary-strong',
'border-x-0 bg-tint-base p-2 first:rounded-l-full first:border-l-1 first:pl-2.5 last:rounded-r-full last:border-r-1 last:pr-2.5 hover:bg-primary-hover hover:text-primary-strong',
'disabled:cursor-not-allowed disabled:hover:bg-inherit disabled:hover:text-inherit dark:disabled:hover:text-inherit',
'ring-tint contrast-more:hover:ring-1',
active
@@ -158,13 +156,13 @@ function RatingButton(
: 'disabled:opacity-7 contrast-more:disabled:ring-0'
)}
type="button"
{...attr}
aria-label={label}
title={label}
variant="secondary"
label={label}
onClick={onClick}
>
{ratingIcon}
</button>
iconOnly
{...attr}
icon={ratingIcon}
/>
);
}
@@ -5,6 +5,7 @@ import type React from 'react';
import { TranslateContext } from '@/intl/client';
import type { TranslationLanguage } from '@/intl/translations';
import { TooltipProvider } from '@radix-ui/react-tooltip';
import { HashProvider } from '../hooks';
import { LoadingStateProvider } from '../primitives/LoadingStateProvider';
export function ClientContexts(props: {
@@ -16,7 +17,9 @@ export function ClientContexts(props: {
return (
<TranslateContext.Provider value={language}>
<TooltipProvider delayDuration={200}>
<LoadingStateProvider>{children}</LoadingStateProvider>
<HashProvider>
<LoadingStateProvider>{children}</LoadingStateProvider>
</HashProvider>
</TooltipProvider>
</TranslateContext.Provider>
);
@@ -15,6 +15,8 @@ export function HighlightQuery(props: {
query,
text,
highlight = [
'-z-1',
'relative',
'text-bold',
'bg-primary',
'text-contrast-primary',
@@ -33,7 +35,7 @@ export function HighlightQuery(props: {
const matches = matchString(text, query);
return (
<span className={tcls('whitespace-break-spaces')}>
<span className={tcls('relative z-2 whitespace-break-spaces')}>
{matches.map((entry, index) => (
<span key={index} className={tcls(entry.match ? highlight : null)}>
{entry.text}
@@ -16,9 +16,9 @@ import { SearchScopeToggle } from './SearchScopeToggle';
import { useSearch } from './useSearch';
interface SearchContainerProps {
spaceId: string;
siteSpaceId: string;
spaceTitle: string;
isMultiVariants: boolean;
withVariants: boolean;
aiMode: CustomizationAIMode;
className?: string;
}
@@ -27,7 +27,7 @@ interface SearchContainerProps {
* Client component to render the search input and results.
*/
export function SearchContainer(props: SearchContainerProps) {
const { spaceId, spaceTitle, isMultiVariants, aiMode, className } = props;
const { siteSpaceId, spaceTitle, withVariants, aiMode, className } = props;
const withAIChat = aiMode === CustomizationAIMode.Assistant;
const [state, setSearchState] = useSearch(withAIChat);
@@ -142,7 +142,7 @@ export function SearchContainer(props: SearchContainerProps) {
// Only show content if there's a query or Ask is enabled
(state?.query || aiMode !== CustomizationAIMode.None) && open ? (
<React.Suspense fallback={null}>
{isMultiVariants && !showAsk ? (
{withVariants && !showAsk ? (
<SearchScopeToggle spaceTitle={spaceTitle} />
) : null}
{state !== null && !showAsk ? (
@@ -151,7 +151,7 @@ export function SearchContainer(props: SearchContainerProps) {
query={normalizedQuery}
global={state?.global ?? false}
aiMode={aiMode}
spaceId={spaceId}
siteSpaceId={siteSpaceId}
/>
) : null}
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
@@ -52,11 +52,11 @@ export const SearchResults = React.forwardRef(function SearchResults(
query: string;
global: boolean;
aiMode: CustomizationAIMode;
spaceId: string;
siteSpaceId: string;
},
ref: React.Ref<SearchResultsRef>
) {
const { children, query, aiMode, global, spaceId } = props;
const { children, query, aiMode, global, siteSpaceId } = props;
const language = useLanguage();
const trackEvent = useTrackEvent();
@@ -77,9 +77,12 @@ export const SearchResults = React.forwardRef(function SearchResults(
return;
}
if (cachedRecommendedQuestions.has(spaceId)) {
const results = cachedRecommendedQuestions.get(spaceId);
assert(results, `Cached recommended questions should be set for space ${spaceId}`);
if (cachedRecommendedQuestions.has(siteSpaceId)) {
const results = cachedRecommendedQuestions.get(siteSpaceId);
assert(
results,
`Cached recommended questions should be set for site-space ${siteSpaceId}`
);
setResultsState({ results, fetching: false });
return;
}
@@ -98,7 +101,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
return;
}
const response = await streamRecommendedQuestions(spaceId);
const response = await streamRecommendedQuestions({ siteSpaceId });
for await (const entry of readStreamableValue(response.stream)) {
if (!entry) {
continue;
@@ -115,7 +118,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
id: question,
question,
});
cachedRecommendedQuestions.set(spaceId, recommendedQuestions);
cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions);
if (!cancelled) {
setResultsState({ results: [...recommendedQuestions], fetching: false });
@@ -22,6 +22,7 @@ import type * as React from 'react';
import { throwIfDataError } from '@/lib/data';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { joinPathWithBaseURL } from '@/lib/paths';
import { traceErrorOnly } from '@/lib/tracing';
import type { IconName } from '@gitbook/icons';
import { DocumentView } from '../DocumentView';
@@ -68,11 +69,13 @@ export interface AskAnswerResult {
* Server action to search content in the entire site.
*/
export async function searchAllSiteContent(query: string): Promise<OrderedComputedResult[]> {
const context = await getServerActionBaseContext();
return traceErrorOnly('Search.searchAllSiteContent', async () => {
const context = await getServerActionBaseContext();
return await searchSiteContent(context, {
query,
scope: { mode: 'all' },
return await searchSiteContent(context, {
query,
scope: { mode: 'all' },
});
});
}
@@ -80,16 +83,18 @@ export async function searchAllSiteContent(query: string): Promise<OrderedComput
* Server action to search content in a space.
*/
export async function searchSiteSpaceContent(query: string): Promise<OrderedComputedResult[]> {
const context = await getServerActionBaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
return traceErrorOnly('Search.searchSiteSpaceContent', async () => {
const context = await getServerActionBaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
return await searchSiteContent(context, {
query,
// If we have a siteSectionId that means its a sections site use `current` mode
// which searches in the current space + all default spaces of sections
scope: siteURLData.siteSection
? { mode: 'current', siteSpaceId: siteURLData.siteSpace }
: { mode: 'specific', siteSpaceIds: [siteURLData.siteSpace] },
return await searchSiteContent(context, {
query,
// If we have a siteSectionId that means its a sections site use `current` mode
// which searches in the current space + all default spaces of sections
scope: siteURLData.siteSection
? { mode: 'current', siteSpaceId: siteURLData.siteSpace }
: { mode: 'specific', siteSpaceIds: [siteURLData.siteSpace] },
});
});
}
@@ -101,120 +106,126 @@ export async function streamAskQuestion({
}: {
question: string;
}) {
const responseStream = createStreamableValue<AskAnswerResult | undefined>();
return traceErrorOnly('Search.streamAskQuestion', async () => {
const responseStream = createStreamableValue<AskAnswerResult | undefined>();
(async () => {
const context = await fetchServerActionSiteContext(await getServerActionBaseContext());
(async () => {
const context = await fetchServerActionSiteContext(await getServerActionBaseContext());
const apiClient = await context.dataFetcher.api();
const apiClient = await context.dataFetcher.api();
const stream = apiClient.orgs.streamAskInSite(
context.organizationId,
context.site.id,
{
question,
context: {
siteSpaceId: context.siteSpace.id,
const stream = apiClient.orgs.streamAskInSite(
context.organizationId,
context.site.id,
{
question,
context: {
siteSpaceId: context.siteSpace.id,
},
scope: {
mode: 'default',
// Include the current site space regardless.
includedSiteSpaces: [context.siteSpace.id],
},
},
scope: {
mode: 'default',
// Include the current site space regardless.
includedSiteSpaces: [context.siteSpace.id],
},
},
{ format: 'document' }
);
const spacePromises = new Map<string, Promise<Revision>>();
for await (const chunk of stream) {
const answer = chunk.answer;
// Register the space of each page source into the promise queue.
const spaces = answer.sources
.map((source) => {
if (source.type !== 'page') {
return null;
}
if (!spacePromises.has(source.space)) {
spacePromises.set(
source.space,
throwIfDataError(
context.dataFetcher.getRevision({
spaceId: source.space,
revisionId: source.revision,
})
)
);
}
return source.space;
})
.filter(filterOutNullable);
// Get the pages for all spaces referenced by this answer.
const pages = await Promise.all(
spaces.map(async (space) => {
const revision = await spacePromises.get(space);
return { space, pages: revision?.pages };
})
).then((results) => {
return results.reduce((map, result) => {
if (result.pages) {
map.set(result.space, result.pages);
}
return map;
}, new Map<string, RevisionPage[]>());
});
responseStream.update(
await transformAnswer(context, { answer: chunk.answer, spacePages: pages })
{ format: 'document' }
);
}
})()
.then(() => {
responseStream.done();
})
.catch((error) => {
responseStream.error(error);
});
return {
stream: responseStream.value,
};
const spacePromises = new Map<string, Promise<Revision>>();
for await (const chunk of stream) {
const answer = chunk.answer;
// Register the space of each page source into the promise queue.
const spaces = answer.sources
.map((source) => {
if (source.type !== 'page') {
return null;
}
if (!spacePromises.has(source.space)) {
spacePromises.set(
source.space,
throwIfDataError(
context.dataFetcher.getRevision({
spaceId: source.space,
revisionId: source.revision,
})
)
);
}
return source.space;
})
.filter(filterOutNullable);
// Get the pages for all spaces referenced by this answer.
const pages = await Promise.all(
spaces.map(async (space) => {
const revision = await spacePromises.get(space);
return { space, pages: revision?.pages };
})
).then((results) => {
return results.reduce((map, result) => {
if (result.pages) {
map.set(result.space, result.pages);
}
return map;
}, new Map<string, RevisionPage[]>());
});
responseStream.update(
await transformAnswer(context, { answer: chunk.answer, spacePages: pages })
);
}
})()
.then(() => {
responseStream.done();
})
.catch((error) => {
responseStream.error(error);
});
return {
stream: responseStream.value,
};
});
}
/**
* Stream a list of suggested questions for the site.
* Optionally scoped to a specific space.
*/
export async function streamRecommendedQuestions(spaceId?: string) {
const siteURLData = await getSiteURLDataFromMiddleware();
const context = await getServerActionBaseContext();
export async function streamRecommendedQuestions(args: { siteSpaceId?: string }) {
return traceErrorOnly('Search.streamRecommendedQuestions', async () => {
const siteURLData = await getSiteURLDataFromMiddleware();
const context = await getServerActionBaseContext();
const responseStream = createStreamableValue<SearchAIRecommendedQuestionStream | undefined>();
const responseStream = createStreamableValue<
SearchAIRecommendedQuestionStream | undefined
>();
(async () => {
const apiClient = await context.dataFetcher.api();
const apiStream = apiClient.orgs.streamRecommendedQuestionsInSite(
siteURLData.organization,
siteURLData.site,
{
spaceId,
(async () => {
const apiClient = await context.dataFetcher.api();
const apiStream = apiClient.orgs.streamRecommendedQuestionsInSite(
siteURLData.organization,
siteURLData.site,
{
siteSpaceId: args.siteSpaceId,
}
);
for await (const chunk of apiStream) {
responseStream.update(chunk);
}
);
})()
.then(() => {
responseStream.done();
})
.catch((error) => {
responseStream.error(error);
});
for await (const chunk of apiStream) {
responseStream.update(chunk);
}
})()
.then(() => {
responseStream.done();
})
.catch((error) => {
responseStream.error(error);
});
return { stream: responseStream.value };
return { stream: responseStream.value };
});
}
/**
@@ -135,6 +135,9 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
canonical: linker
.toAbsoluteURL(linker.toPathForPage({ pages: revision.pages, page }))
.replace(/\/+$/, ''),
types: {
'text/markdown': `${linker.toAbsoluteURL(linker.toPathInSpace(page.path))}.md`,
},
},
openGraph: {
images: [
@@ -1,5 +1,9 @@
import type { GitBookSiteContext } from '@/lib/context';
import { CustomizationAIMode, CustomizationHeaderPreset } from '@gitbook/api';
import {
CustomizationAIMode,
CustomizationHeaderPreset,
CustomizationSearchStyle,
} from '@gitbook/api';
import React from 'react';
import { Footer } from '@/components/Footer';
@@ -40,8 +44,18 @@ export function SpaceLayout(props: {
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
const currentLanguage =
siteSpace.space.language ?? context.customization.internationalization.locale;
const withSections = Boolean(sections && sections.list.length > 1);
const isMultiVariants = Boolean(siteSpaces.length > 1);
const withVariants: 'generic' | 'translations' | undefined =
siteSpaces.length > 1
? siteSpaces.some(
(space) => space.space.language && space.space.language !== currentLanguage
)
? 'translations'
: 'generic'
: undefined;
const withFooter =
customization.themes.toggeable ||
@@ -56,13 +70,19 @@ export function SpaceLayout(props: {
<React.Suspense fallback={null}>
<SearchContainer
aiMode={aiMode}
isMultiVariants={siteSpaces.length > 1}
withVariants={Boolean(withVariants)}
spaceTitle={siteSpace.title}
spaceId={siteSpace.space.id}
siteSpaceId={siteSpace.id}
/>
</React.Suspense>
{aiMode === CustomizationAIMode.Assistant ? (
<AIChatButton trademark={customization.trademark.enabled} />
<AIChatButton
withLabel={
withTopHeader &&
customization.styling.search === CustomizationSearchStyle.Prominent
}
trademark={customization.trademark.enabled}
/>
) : null}
</div>
);
@@ -86,7 +106,12 @@ export function SpaceLayout(props: {
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
>
<Announcement context={context} />
<Header withTopHeader={withTopHeader} context={context} search={searchAndAI} />
<Header
withVariants={withVariants}
withTopHeader={withTopHeader}
context={context}
search={searchAndAI}
/>
{aiMode === CustomizationAIMode.Assistant ? (
<AIChat trademark={customization.trademark.enabled} />
) : null}
@@ -137,13 +162,16 @@ export function SpaceLayout(props: {
)}
/>
)}
{isMultiVariants && !sections && (
{withVariants && (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
className={tcls(
'w-full',
withVariants === 'translations'
? 'xl:hidden'
: '',
'page-no-toc:hidden',
'page-no-toc:site-header-none:flex'
)}
@@ -14,7 +14,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup }) {
<li className="group/page-group-item flex flex-col">
<div
className={tcls(
'-top-5 group-first/page-group-item:-mt-5 sticky z-1 flex items-center gap-3 px-3 pt-6',
'-top-6 group-first/page-group-item:-mt-6 sticky z-1 flex items-center gap-3 px-3 pt-6',
'font-semibold text-xs uppercase tracking-wide',
'pb-3', // Add extra padding to make the header fade a bit nicer
'-mb-1.5', // Then pull the page items a bit closer, effective bottom padding is 1.5 units / 6px.
@@ -18,16 +18,17 @@ export function TableOfContentsScript() {
// Set sticky top position based on header
const headerHeight = header?.offsetHeight ?? 0;
root.style.setProperty('--toc-top-offset', `${headerHeight}px`);
// Start with full viewport height minus header
let height = window.innerHeight - headerHeight;
let offset = headerHeight;
// Subtract visible banner (if any)
if (banner && window.getComputedStyle(banner).display !== 'none') {
const bannerRect = banner.getBoundingClientRect();
if (bannerRect.height > 0 && bannerRect.bottom > 0) {
height -= Math.min(bannerRect.height, bannerRect.bottom);
offset += Math.min(bannerRect.height, bannerRect.bottom);
}
}
@@ -41,6 +42,7 @@ export function TableOfContentsScript() {
// Update height
root.style.setProperty('--toc-height', `${height}px`);
root.style.setProperty('--toc-top-offset', `${offset}px`);
};
// Initial update
@@ -1,11 +1,11 @@
'use client';
import { Icon, type IconName } from '@gitbook/icons';
import type { IconName } from '@gitbook/icons';
import { useTheme } from 'next-themes';
import React from 'react';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Button, ButtonGroup } from '../primitives';
type ThemeMode = 'light' | 'system' | 'dark';
@@ -27,7 +27,7 @@ export function ThemeToggler() {
};
return (
<div role="radiogroup" className="flex flex-row gap-2">
<ButtonGroup role="radiogroup" className="gap-2" combinedShape={false}>
<ThemeButton
active={mounted && theme === 'light'}
icon="sun-bright"
@@ -46,7 +46,7 @@ export function ThemeToggler() {
onClick={() => onSwitchMode('dark')}
title={tString(language, 'switch_to_dark_theme')}
/>
</div>
</ButtonGroup>
);
}
@@ -58,38 +58,21 @@ function ThemeButton(props: {
}) {
const { icon, onClick, title, active } = props;
return (
<button
<Button
type="button"
role="radio"
onClick={onClick}
aria-label={title}
title={title}
label={title}
aria-checked={active}
className={tcls(
'p-2',
'rounded-sm',
'straight-corners:rounded-none',
'circular-corners:rounded-full',
'transition-all',
'text-tint',
'contrast-more:text-tint-strong',
'hover:bg-tint-hover',
'contrast-more:hover:ring-2',
'contrast-more:focus:ring-2',
'ring-tint',
active && [
'bg-primary',
'theme-muted:bg-primary-hover',
'[html.sidebar-filled.theme-bold.tint_&]:bg-primary-hover',
'hover:bg-primary',
'text-primary-strong',
'contrast-more:text-primary-strong',
'contrast-more:ring-1',
'ring-primary',
]
)}
>
<Icon icon={icon} className={tcls('size-4')} />
</button>
variant="blank"
size="default"
className={
active
? 'bg-primary theme-muted:bg-primary-hover text-primary-strong ring-primary hover:bg-primary contrast-more:text-primary-strong contrast-more:ring-1 [html.sidebar-filled.theme-bold.tint_&]:bg-primary-hover'
: ''
}
icon={icon}
iconOnly
/>
);
}
@@ -1,24 +0,0 @@
'use client';
import { useParams } from 'next/navigation';
import React from 'react';
function getHash(): string | null {
if (typeof window === 'undefined') {
return null;
}
return window.location.hash.slice(1);
}
/**
* Hook to get the current hash from the URL.
* @see https://github.com/vercel/next.js/discussions/49465
*/
export function useHash() {
const params = useParams();
const [hash, setHash] = React.useState<string | null>(getHash);
React.useEffect(() => {
setHash(getHash());
}, [params]);
return hash;
}
@@ -0,0 +1,52 @@
'use client';
import React from 'react';
export const HashContext = React.createContext<{
hash: string | null;
/**
* Updates the hash value from the URL provided here.
* It will then be used by the `useHash` hook.
* URL can be relative or absolute.
*/
updateHashFromUrl: (href: string) => void;
}>({
hash: null,
updateHashFromUrl: () => {},
});
function getHash(): string | null {
if (typeof window === 'undefined') {
return null;
}
return window.location.hash.slice(1);
}
export const HashProvider: React.FC<React.PropsWithChildren<{}>> = ({ children }) => {
const [hash, setHash] = React.useState<string | null>(getHash);
const updateHashFromUrl = React.useCallback((href: string) => {
const url = new URL(
href,
typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
);
setHash(url.hash.slice(1));
}, []);
const memoizedValue = React.useMemo(
() => ({ hash, updateHashFromUrl }),
[hash, updateHashFromUrl]
);
return <HashContext.Provider value={memoizedValue}>{children}</HashContext.Provider>;
};
/**
* Hook to get the current hash from the URL.
* @see https://github.com/vercel/next.js/discussions/49465
* We use a different hack than this one, because for same page link it don't work
* We can't use the `hashChange` event because it doesn't fire for `replaceState` and `pushState` which are used by Next.js.
* Since we have a single Link component that handles all links, we can use a context to share the hash.
*/
export function useHash() {
// const params = useParams();
const { hash } = React.useContext(HashContext);
return hash;
}
@@ -31,16 +31,16 @@ export const variantClasses = {
'hover:text-contrast-primary-solid-hover',
'border-0',
'contrast-more:border',
'disabled:bg-tint',
'disabled:text-tint/8',
'disabled:bg-primary-subtle',
'disabled:text-primary/8',
],
blank: [
'bg-transparent',
'text-tint',
'border-0',
'shadow-none!',
'hover:bg-primary-hover',
'hover:text-primary-strong',
'hover:bg-tint-hover',
'hover:text-tint-strong',
'contrast-more:bg-tint-subtle',
'hover:depth-subtle:translate-y-0',
'disabled:text-tint/8',
@@ -52,7 +52,7 @@ export const variantClasses = {
'text-tint',
'hover:bg-tint-hover',
'hover:depth-flat:bg-tint-hover',
'hover:text-primary',
'hover:text-tint',
'contrast-more:bg-tint-subtle',
'disabled:bg-transparent',
'disabled:text-tint/8',
@@ -135,7 +135,7 @@ export const Button = React.forwardRef<
icon
)
) : null}
{iconOnly ? null : label}
{iconOnly ? null : (children ?? label)}
</>
);
@@ -171,7 +171,7 @@ export const Button = React.forwardRef<
</button>
);
return iconOnly && label ? (
return (children || iconOnly) && label ? (
<Tooltip
rootProps={{ open: disabled === true ? false : undefined }}
label={label}
@@ -185,14 +185,21 @@ export const Button = React.forwardRef<
}
);
export const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonProps>(({ children }, ref) => {
export const ButtonGroup = React.forwardRef<
HTMLDivElement,
ButtonProps & { combinedShape?: boolean }
>(({ children, className, combinedShape = true, ...rest }, ref) => {
return (
<div
ref={ref}
className={tcls(
'flex h-fit items-stretch justify-start *:translate-y-0! *:shadow-none!',
'[&>*: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'
'flex h-fit items-stretch justify-start',
combinedShape
? '*:translate-y-0! *:shadow-none! [&>*: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'
: '',
className
)}
{...rest}
>
{children}
</div>
@@ -6,6 +6,7 @@ import React from 'react';
import { tcls } from '@/lib/tailwind';
import { SiteExternalLinksTarget } from '@gitbook/api';
import { type TrackEventInput, useTrackEvent } from '../Insights';
import { HashContext } from '../hooks';
import { isExternalLink } from '../utils/link';
import { type DesignTokenName, useClassnames } from './StyleProvider';
@@ -71,6 +72,7 @@ export const Link = React.forwardRef(function Link(
) {
const { href, prefetch, children, insights, classNames, className, ...domProps } = props;
const { externalLinksTarget } = React.useContext(LinkSettingsContext);
const { updateHashFromUrl } = React.useContext(HashContext);
const trackEvent = useTrackEvent();
const forwardedClassNames = useClassnames(classNames || []);
const isExternal = isExternalLink(href);
@@ -78,6 +80,9 @@ export const Link = React.forwardRef(function Link(
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
const isExternalWithOrigin = isExternalLink(href, window.location.origin);
if (!isExternal) {
updateHashFromUrl(href);
}
if (insights) {
trackEvent(insights, undefined, { immediate: isExternalWithOrigin });
@@ -17,7 +17,7 @@ export const ButtonStyles = [
'depth-subtle:shadow-xs',
'hover:depth-subtle:shadow-md',
'focus-visible:depth-subtle:shadow-md',
'active:shadow-none',
'active:depth-subtle:shadow-xs',
'shadow-tint/6',
'dark:shadow-tint-1',
@@ -27,6 +27,7 @@ export const ButtonStyles = [
'hover:depth-subtle:-translate-y-px',
'focus-visible:depth-subtle:-translate-y-px',
'active:depth-subtle:translate-y-0',
'transition-all',
'grow-0',
@@ -8,6 +8,7 @@ import { ja } from './ja';
import { nl } from './nl';
import { no } from './no';
import { pt_br } from './pt-br';
import { ru } from './ru';
import type { TranslationLanguage } from './types';
import { zh } from './zh';
@@ -27,4 +28,5 @@ export const languages: {
nl,
no,
'pt-br': pt_br,
ru,
};
@@ -0,0 +1,109 @@
export const ru = {
locale: 'ru',
powered_by_gitbook: 'Работает на GitBook',
sponsored_via_gitbook: 'Спонсируется GitBook',
switch_to_dark_theme: 'Переключиться на тёмную тему',
switch_to_light_theme: 'Переключиться на светлую тему',
switch_to_system_theme: 'Переключиться на системную тему',
search: 'Поиск',
search_or_ask: 'Найти или спросить',
search_input_placeholder: 'Поиск по содержимому',
search_ask_input_placeholder: 'Найти в содержимом или задать вопрос',
search_no_results_for: 'Нет результатов для "${1}".',
search_no_results: 'Нет результатов',
search_results_count: '${1} — число результатов',
search_scope_space: '${1}',
search_scope_all: 'Все материалы',
ask: 'Спросить',
search_ask: 'Спросить "${1}"',
search_ask_description: 'Найти ответ с помощью ИИ',
search_ask_sources: 'Источники',
search_ask_sources_no_answer: 'Похожие страницы',
search_ask_no_answer:
'Не удалось найти ответ на ваш вопрос. Попробуйте перефразировать его или задать более конкретный вопрос.',
search_ask_error: 'Что-то пошло не так. Пожалуйста, попробуйте позже.',
on_this_page: 'На этой странице',
next_page: 'Следующая',
previous_page: 'Предыдущая',
page_last_modified: 'Последнее обновление ${1}',
was_this_helpful: 'Это было полезно?',
was_this_helpful_positive: 'Да, было!',
was_this_helpful_neutral: 'Не знаю',
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: 'Скопировано!',
code_copy: 'Копировать',
table_of_contents_button_label: 'Открыть оглавление',
cookies_title: 'Файлы cookies',
cookies_prompt:
'Этот сайт использует файлы cookies для предоставления услуг и анализа трафика. Просматривая этот сайт, вы принимаете условия ${1}.',
cookies_prompt_privacy: 'политики конфиденциальности',
cookies_accept: 'Принять',
cookies_reject: 'Отклонить',
close: 'Закрыть',
view: 'Посмотреть',
edit_on_git: 'Редактировать в ${1}',
notfound_title: 'Страница не найдена',
notfound_link: 'Эта ссылка ведёт на страницу, которая была удалена или больше не существует.',
notfound: 'Страница, которую вы ищете, не существует.',
unexpected_error_title: 'Произошла ошибка',
unexpected_error: 'Извините, произошла непредвиденная ошибка. Пожалуйста, попробуйте позже.',
unexpected_error_retry: 'Попробуйте снова',
pdf_download: 'Экспортировать как PDF',
pdf_goback: 'Вернуться к материалу',
pdf_print: 'Напечатать или сохранить как PDF',
pdf_page_of: '${1} из ${2}',
pdf_mode_only_page: 'Только эта страница',
pdf_mode_all: 'Все страницы',
pdf_limit_reached:
'Не удалось создать PDF-файл для ${1} страниц, генерация остановлена ​​на ${2}.',
pdf_limit_reached_continue: 'Расширьте ещё на ${1} страниц.',
more: 'Ещё',
link_tooltip_external_link: 'Внешняя ссылка на',
link_tooltip_page_anchor: 'Перейти к разделу',
open_in_new_tab: 'Открыть в новой вкладке',
ai_chat_assistant_name: 'GitBook-помощник',
ai_chat_assistant_name_unbranded: 'ИИ-помощник',
ai_chat_assistant_description: 'Я здесь, чтобы помочь вам с базой знаний.',
ai_chat_assistant_greeting_morning: 'Доброе утро',
ai_chat_assistant_greeting_afternoon: 'Добрый день',
ai_chat_assistant_greeting_evening: 'Добрый вечер',
ai_chat_assistant_greeting_night: 'Доброй ночи',
ai_chat_clear_conversation: 'Очистить диалог',
ai_chat_thinking: 'Думает…',
ai_chat_working: 'Работает…',
ai_chat_context_badge: 'ИИ',
ai_chat_error: 'Что-то пошло не так.',
ai_chat_context_title: 'На основе вашего контекста',
ai_chat_context_description:
'Помощник использует ИИ и ваш контекст для генерации ответов и выполнения действий.',
ai_chat_context_pages_youve_read: 'Страницы, которые вы прочитали',
ai_chat_context_info_provided_by_the_site: 'Информация предоставлена ​​сайтом',
ai_chat_context_previous_messages: 'Предыдущие сообщения',
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: 'Что мне нужно прочитать дальше?',
ai_chat_suggested_questions_example: 'Можете ли вы привести пример?',
searched_for: 'Выполнен поиск ${1}',
ai_chat_tools_listed_pages: 'Просмотрены документы',
ai_chat_tools_read_page: 'Прочитана страница ${1}',
ai_chat_tools_mcp_tool: 'Вызван ${1}',
ai_chat_ask: 'Спросить ${1}',
ai_chat_ask_about_page: 'Спросить ${1} об этой странице',
copy_for_llms: 'Скопировать для LLM',
copy_page_markdown: 'Скопировать страницу как Markdown для LLM',
copy_page: 'Скопировать страницу',
view_page_markdown: 'Посмотреть как Markdown',
view_page_plaintext: 'Посмотреть как текст без форматирования',
open_in: 'Открыть в ${1}',
open_in_llms_pre_prompt: 'Прочитай ${1} и ответь на вопросы о содержимом.',
};
+40
View File
@@ -92,6 +92,13 @@ export function createDataFetcher(
pageId: params.pageId,
});
},
getRevisionReusableContentDocument(params) {
return getRevisionReusableContentDocument(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
reusableContentId: params.reusableContentId,
});
},
getLatestOpenAPISpecVersionContent(params) {
return getLatestOpenAPISpecVersionContent(input, {
organizationId: params.organizationId,
@@ -354,6 +361,39 @@ const getRevisionPageDocument = cache(
}
);
const getRevisionReusableContentDocument = cache(
async (
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; reusableContentId: string }
) => {
'use cache';
return wrapCacheDataFetcherError(async () => {
return trace(
`getRevisionReusableContentDocument(${params.spaceId}, ${params.revisionId}, ${params.reusableContentId})`,
async () => {
const api = apiClient(input);
const res = await api.spaces.getReusableContentDocumentInRevisionById(
params.spaceId,
params.revisionId,
params.reusableContentId,
{
evaluated: true,
},
{
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLifeFromResponse(res, 'max');
return res.data;
}
);
});
}
);
const getRevisionPageByPath = cache(
async (
input: DataFetcherInput,
+9
View File
@@ -100,6 +100,15 @@ export interface GitBookDataFetcher {
pageId: string;
}): Promise<DataFetcherResponse<api.JSONDocument>>;
/**
* Get the document of a reusable content by its space ID and reusable content ID.
*/
getRevisionReusableContentDocument(params: {
spaceId: string;
revisionId: string;
reusableContentId: string;
}): Promise<DataFetcherResponse<api.JSONDocument>>;
/**
* Get a document by its space ID and document ID.
*/
+16 -18
View File
@@ -36,25 +36,28 @@ export async function fetchOpenAPIFilesystem(
return { filesystem: null, specUrl: null };
}
const filesystem = await (() => {
const result = await (() => {
// If the reference is a new OpenAPI reference, we return it.
if (ref.kind === 'openapi') {
assert(resolved.openAPIFilesystem);
return resolved.openAPIFilesystem;
}
// For legacy blocks ("swagger"), we need to fetch the file system.
return fetchFilesystem(resolved.href, context.space.id);
})();
if ('error' in filesystem) {
throw new OpenAPIParseError(filesystem.error.message, { code: filesystem.error.code });
if ('error' in result) {
throw new OpenAPIParseError(result.error.message, { code: result.error.code });
}
return {
filesystem,
specUrl: resolved.href,
};
return { filesystem: result, specUrl: resolved.href };
}
const fetchFilesystem = async (
/**
* Fetch the filesystem from the URL.
* It's used for legacy "swagger" blocks.
*/
async function fetchFilesystem(
url: string,
spaceId: string
): Promise<
@@ -65,11 +68,11 @@ const fetchFilesystem = async (
message: string;
};
}
> => {
> {
'use cache';
try {
cacheTag(getCacheTag({ tag: 'space', space: spaceId }));
return await fetchFilesystemUncached(url);
return await fetchFilesystemNoCache(url);
} catch (error) {
// To avoid hammering the file with requests, we cache the error for around a minute.
cacheLife('minutes');
@@ -86,21 +89,16 @@ const fetchFilesystem = async (
console.error('Unknown error while fetching OpenAPI file:', error);
return { error: { code: 'invalid' as const, message: 'Unknown error' } };
}
};
}
async function fetchFilesystemUncached(
url: string,
options?: {
signal?: AbortSignal;
}
) {
async function fetchFilesystemNoCache(url: string) {
console.log(url);
// Wrap the raw string to prevent invalid URLs from being passed to fetch.
// This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch:
// https://github.com/cloudflare/workerd/issues/1957
const response = await fetch(new URL(url), {
...noCacheFetchOptions,
cache: 'no-store',
signal: options?.signal,
});
if (!response.ok) {
@@ -9,10 +9,7 @@ import type {
type ResolveOpenAPIOperationBlockResult = ResolveOpenAPIBlockResult<OpenAPIOperationData>;
const weakmap = new WeakMap<
AnyOpenAPIOperationsBlock,
Promise<ResolveOpenAPIOperationBlockResult>
>();
const cache = new WeakMap<AnyOpenAPIOperationsBlock, Promise<ResolveOpenAPIOperationBlockResult>>();
/**
* Cache the result of resolving an OpenAPI block.
@@ -21,22 +18,24 @@ const weakmap = new WeakMap<
export function resolveOpenAPIOperationBlock(
args: ResolveOpenAPIBlockArgs<AnyOpenAPIOperationsBlock>
): Promise<ResolveOpenAPIOperationBlockResult> {
if (weakmap.has(args.block)) {
return weakmap.get(args.block)!;
const inCache = cache.get(args.block);
if (inCache) {
return inCache;
}
const result = baseResolveOpenAPIOperationBlock(args);
weakmap.set(args.block, result);
return result;
const promise = resolveOpenAPIOperationBlockNoCache(args);
cache.set(args.block, promise);
return promise;
}
/**
* Resolve OpenAPI operation block.
*/
async function baseResolveOpenAPIOperationBlock(
async function resolveOpenAPIOperationBlockNoCache(
args: ResolveOpenAPIBlockArgs<AnyOpenAPIOperationsBlock>
): Promise<ResolveOpenAPIOperationBlockResult> {
const { context, block } = args;
if (!block.data.path || !block.data.method) {
return { data: null, specUrl: null };
}
+35
View File
@@ -49,6 +49,41 @@ export async function trace<T>(
}
}
/**
* Record a performance trace for the given function, but only log errors.
* This is useful to not output too much noise in the logs, while still capturing important errors.
*/
export async function traceErrorOnly<T>(
name: string | TraceName,
fn: (span: TraceSpan) => Promise<T>
): Promise<T> {
const { operation, name: executionName } =
typeof name === 'string' ? { operation: name, name: undefined } : name;
const completeName = executionName ? `${operation}(${executionName})` : operation;
const attributes: Record<string, boolean | string | number> = {};
const span: TraceSpan = {
setAttribute(label, value) {
attributes[label] = value;
},
};
const start = now();
let traceError: null | Error = null;
try {
return await fn(span);
} catch (error) {
span.setAttribute('error', true);
traceError = error as Error;
const logger = getLogger().subLogger(operation);
logger.error(
`trace ${completeName} failed with ${traceError.message} in ${now() - start}ms`,
attributes
);
throw error;
}
}
/**
* Return the current time in milliseconds.
*/
+24 -10
View File
@@ -64,21 +64,35 @@ export async function middleware(request: NextRequest) {
}
async function validateServerActionRequest(request: NextRequest) {
// We need to reject incorrect server actions requests
// We do not do it in cloudflare workers as there is a bug that prevents us from reading the request body.
if (request.headers.has('next-action') && process.env.GITBOOK_RUNTIME !== 'cloudflare') {
// We just test that the json body is parseable
try {
const clonedRequest = request.clone();
await clonedRequest.json();
} catch (e) {
console.warn('Invalid server action request', e);
// If the body is not parseable, we reject the request
// First thing we need to do is validate that the header is in a correct format.
if (request.headers.has('next-action')) {
// A server action id is a 1-byte hex string (2 chars) followed by a 20-byte SHA1 hash (40 chars) = 42 total characters.
// For ref https://github.com/vercel/next.js/blob/db561cb924cbea0f3384e89f251fc443a8aec1ae/crates/next-custom-transforms/src/transforms/server_actions.rs#L266-L268
const regex = /^[a-fA-F0-9]{42}$/;
const match = request.headers.get('next-action')?.match(regex);
if (!match) {
return new Response('Invalid request', {
status: 400,
headers: { 'content-type': 'text/plain' },
});
}
// We need to reject incorrect server actions requests
// We do not do it in cloudflare workers as there is a bug that prevents us from reading the request body.
if (process.env.GITBOOK_RUNTIME !== 'cloudflare') {
// We just test that the json body is parseable
try {
const clonedRequest = request.clone();
await clonedRequest.json();
} catch (e) {
console.warn('Invalid server action request', e);
// If the body is not parseable, we reject the request
return new Response('Invalid request', {
status: 400,
headers: { 'content-type': 'text/plain' },
});
}
}
}
}
+21 -2
View File
@@ -87,6 +87,8 @@ export async function serveResizedImage(
options.height = Number(height);
}
const longestEdgeValue = Math.max(options.width || 0, options.height || 0);
const dpr = requestURL.searchParams.get('dpr');
if (dpr) {
options.dpr = Number(dpr);
@@ -99,10 +101,14 @@ export async function serveResizedImage(
// Check the Accept header to handle content negotiation
const accept = request.headers.get('accept');
if (accept && /image\/avif/.test(accept)) {
// We use transform image, max size for avif should be 1600
// https://developers.cloudflare.com/images/transform-images/#limits-per-format
if (accept && /image\/avif/.test(accept) && longestEdgeValue <= 1600) {
options.format = 'avif';
} else if (accept && /image\/webp/.test(accept)) {
options.dpr = chooseDPR(longestEdgeValue, 1600, options.dpr);
} else if (accept && /image\/webp/.test(accept) && longestEdgeValue <= 1920) {
options.format = 'webp';
options.dpr = chooseDPR(longestEdgeValue, 1920, options.dpr);
}
try {
@@ -119,6 +125,19 @@ export async function serveResizedImage(
}
}
/**
* Choose the appropriate device pixel ratio (DPR) based on the longest edge of the image.
* This function ensures that the DPR is within a reasonable range (1 to 3).
* This is only used for AVIF/WebP formats to avoid issues with Cloudflare resizing.
* It means that dpr may not be respected for avif/webp formats, but it will also improve the cache hit ratio.
*/
function chooseDPR(longestEdgeValue: number, maxAllowedSize: number, wantedDpr?: number): number {
const maxDprBySize = Math.floor(maxAllowedSize / longestEdgeValue);
const clampedDpr = Math.min(wantedDpr ?? 1, 3); // Limit to a maximum of 3, default to 1 if not specified
// Ensure that the DPR is within the allowed range
return Math.max(1, Math.min(maxDprBySize, clampedDpr));
}
/**
* Parse the image signature version from a query param. Returns null if the version is invalid.
*/
+14 -1
View File
@@ -398,7 +398,20 @@ async function fetchImage(url: string, options?: ResizeImageOptions) {
try {
const { width, height } = imageSize(buffer);
return { src, width, height };
// If we provide a width and height in the options, we always want to use them
// The resize in cloudflare can fail and will fallback to the original size, which could stretch the image
// If the image is smaller than the requested size, it will also return the original image
if (
(options?.width && options.width !== width) ||
(options?.height && options.height !== height)
) {
return {
src,
width: options.width,
height: options.height,
};
}
return { src, width: width, height: height };
} catch {
return null;
}
+3 -1
View File
@@ -39,7 +39,9 @@ describe('#parseOpenAPI', () => {
});
} catch (error) {
if (error instanceof OpenAPIParseError) {
expect(error.message).toContain('Invalid OpenAPI document');
expect(error.message).toContain(
'Can’t find supported Swagger/OpenAPI version in the provided document, version must be a string.'
);
}
}
});
+14 -1
View File
@@ -12,6 +12,19 @@ export async function parseOpenAPIV3(input: ParseOpenAPIInput): Promise<ParseOpe
const { value, rootURL, options = {} } = input;
const result = await validate(value);
// If there is no version, we consider it invalid instantely.
if (!result.version) {
throw new OpenAPIParseError(
'Can’t find supported Swagger/OpenAPI version in the provided document, version must be a string.',
{
code: 'invalid',
rootURL,
errors: result.errors,
}
);
}
// If the version is 2.0, we throw an error to trigger the upgrade.
if (result.version === '2.0') {
throw new OpenAPIParseError('Only OpenAPI v3 is supported', {
code: 'parse-v2-in-v3',
@@ -21,7 +34,7 @@ export async function parseOpenAPIV3(input: ParseOpenAPIInput): Promise<ParseOpe
// We don't rely on `result.invalid` because it's too strict.
// If we succeed in parsing a schema, then we consider it valid.
if (!result.specification || !result.version) {
if (!result.specification) {
throw new OpenAPIParseError('Invalid OpenAPI document', {
code: 'invalid',
rootURL,