Compare commits

..

31 Commits

Author SHA1 Message Date
Samy Pessé 2a3bb0eb22 Version Packages (#3453)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-15 17:40:24 +02:00
conico974 6816f0fa5a Add browserslist and improve CSS masks for compatibility (#3471)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-15 17:15:56 +02:00
Greg Bergé 17386776ec Add missing changeset to release @gitbook/openapi-parser (#3477) 2025-07-15 12:01:40 +00:00
Nolann B. 9ee9082614 Improvement for CopyMarkdown action (#3465) 2025-07-15 13:41:25 +02:00
Nolann B. 334cfdd522 Fix responsive class for SearchInput button (#3476) 2025-07-15 12:50:57 +02:00
Greg Bergé 8c961a5dbd Fix OpenAPI spec validation (#3473) 2025-07-15 08:56:08 +02:00
conico974 4708956e77 Ensure schema.security is always an array (#3472)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-14 22:01:06 +02:00
Zeno Kapitein 813dd03dbb Fix AIChatInput focus & styling (#3470)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-07-14 11:48:34 +01:00
conico974 0522dbc61e Handle markdown errors gracefully (#3468)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-11 19:11:09 +02:00
Zeno Kapitein acb9f53156 Inline search modal (#3416) 2025-07-11 16:29:03 +01:00
conico974 6016846156 Add cache life inference from API response headers (#3467)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-11 17:10:00 +02:00
Greg Bergé cf1fae5837 Fix using CMD or CTRL to open in a new tab (#3466) 2025-07-11 14:25:37 +00:00
Nolann B. 7212345466 Fix AI Actions dropdown and LLM integration (#3464) 2025-07-11 12:14:06 +02:00
conico974 e38caf78ff Implement consistent logging across the application (#3368)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-11 10:36:03 +02:00
Greg Bergé 0a8dd67541 Better aria for breadcrumbs (#3461) 2025-07-11 09:45:14 +02:00
Nolann B. ed684c19a9 Move AI Actions markdown fetching to client-side (#3459) 2025-07-10 16:05:53 +02:00
Zeno Kapitein 52ab3681e3 Reposition AI Actions dropdown (#3460) 2025-07-09 14:38:23 +00:00
Zeno Kapitein 9cc5a787e9 Generalise keyboard shortcuts, add Cmd+J to AI Chat (#3457) 2025-07-09 12:13:36 +00:00
Nolann B. 0003030bf5 Implement AI actions dropdown (#3431) 2025-07-08 23:33:36 +02:00
Zeno Kapitein 9ad8f45d38 Rebrand assistant (#3458) 2025-07-08 21:31:14 +02:00
conico974 a3855257c8 Gracefully handle OpenAPI fetch errors (#3455)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-08 17:35:23 +02:00
conico974 9169c2f053 Fix site redirect resolution with length check for source paths (#3454)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-08 17:15:57 +02:00
Greg Bergé abe028bd38 Fix Link behaviour reloading the page (#3456) 2025-07-08 14:30:12 +00:00
conico974 12c2451f4f Bump OpenNext to latest versions (#3451)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-08 15:06:23 +02:00
Samy Pessé 8daede5427 Ensure all content links are resolved relatively to preview (#3450) 2025-07-08 13:05:31 +02:00
Samy Pessé 4c89aa60eb Adapt cache header parsing to support s-maxage (#3452) 2025-07-08 12:55:00 +02:00
Greg Bergé d0a3f64ae4 Always open header logo link in the current tab (#3445) 2025-07-08 09:02:49 +00:00
Samy Pessé 433d66482d Version Packages (#3446)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-08 11:01:57 +02:00
Greg Bergé bd553bcf9e Expose isJson and isYaml in @gitbook/openapi-parser (#3447) 2025-07-08 10:41:46 +02:00
Nolann B. f5894bcf74 Fix incorrect revision ID in Markdown fetch logic (#3448) 2025-07-08 09:41:12 +02:00
Zeno Kapitein 1b59e7ce36 Styling improvements to AI Chat Followup questions (#3444) 2025-07-07 19:02:01 +02:00
94 changed files with 2678 additions and 1617 deletions
+15 -14
View File
@@ -37,7 +37,7 @@
},
"packages/fonts": {
"name": "@gitbook/fonts",
"version": "0.0.0",
"version": "0.1.0",
"dependencies": {
"@gitbook/api": "catalog:",
},
@@ -48,7 +48,7 @@
},
"packages/gitbook": {
"name": "gitbook",
"version": "0.12.0",
"version": "0.13.1",
"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.2.1",
"@opennextjs/cloudflare": "^1.4.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-navigation-menu": "^1.2.3",
@@ -69,6 +69,7 @@
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16",
"@tusbar/cache-control": "^1.0.2",
"ai": "^4.2.2",
"assert-never": "^1.2.1",
"bun-types": "^1.1.20",
@@ -96,9 +97,7 @@
"object-identity": "^0.1.2",
"openapi-types": "^12.1.3",
"p-map": "^7.0.3",
"parse-cache-control": "^1.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"quick-lru": "^7.0.1",
"react-hotkeys-hook": "^4.4.1",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
@@ -167,7 +166,7 @@
},
"packages/openapi-parser": {
"name": "@gitbook/openapi-parser",
"version": "2.1.4",
"version": "2.2.0",
"dependencies": {
"@scalar/openapi-parser": "^0.18.0",
"@scalar/openapi-types": "^0.1.9",
@@ -182,7 +181,7 @@
},
"packages/react-contentkit": {
"name": "@gitbook/react-contentkit",
"version": "0.7.0",
"version": "0.7.1",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:*",
@@ -214,7 +213,7 @@
},
"packages/react-openapi": {
"name": "@gitbook/react-openapi",
"version": "1.3.0",
"version": "1.3.2",
"dependencies": {
"@gitbook/openapi-parser": "workspace:*",
"@scalar/api-client-react": "^1.3.16",
@@ -788,9 +787,9 @@
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@opennextjs/aws": ["@opennextjs/aws@3.6.5", "", { "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-wni+CWlRCyWfhNfekQBBPPkrDDnaGdZLN9hMybKI0wKOKTO+zhPOqR65Eh3V0pzWAi84Sureb5mdMuLwCxAAcw=="],
"@opennextjs/aws": ["@opennextjs/aws@3.7.0", "", { "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-JXUZice+CedEQW20hnBVqzBEj+jfr4Oe2LVYSE4RNKdfHVIeYG+WJAop14TxRJ+NugKWGcJx6opf944l+ZG7XQ=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.2.1", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.6.5", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6" }, "peerDependencies": { "wrangler": "^4.19.1" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-cOco+nHwlo/PLB1bThF8IIvaS8PgAT9MEI5ZttFO/qt6spgvr2lUaPkpjgSIQmI3sBIEG2cLUykvQ2nbbZEcVw=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.4.0", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.7.0", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6" }, "peerDependencies": { "wrangler": "^4.19.1" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-nEtxmSo6/y83Qw2TSU8rBpljaGl3NUl5GenD/c71h6YqgbP4QURSxMh7ySe7KK+vtQbilD7WePXovqL4Y6D6dw=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -1272,6 +1271,8 @@
"@tsconfig/strictest": ["@tsconfig/strictest@2.0.5", "", {}, "sha512-ec4tjL2Rr0pkZ5hww65c+EEPYwxOi4Ryv+0MtjeaSQRJyq322Q27eOQiFbuNgw2hpL4hB1/W/HBGk3VKS43osg=="],
"@tusbar/cache-control": ["@tusbar/cache-control@1.0.2", "", {}, "sha512-PXfjYTYBVvMPYCLDWj+xIOA9ITFbbhWCHzLcqUCJ5TPGm4JO4cxpGb7x3K8Q1K1ADgNgfBxLsDcTMVRydtZB9A=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
@@ -2412,8 +2413,6 @@
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-cache-control": ["parse-cache-control@1.0.1", "", {}, "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="],
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"parse-ms": ["parse-ms@2.1.0", "", {}, "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA=="],
@@ -2504,7 +2503,7 @@
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="],
"quick-lru": ["quick-lru@7.0.1", "", {}, "sha512-kLjThirJMkWKutUKbZ8ViqFc09tDQhlbQo2MNuVeLWbRauqYP96Sm6nzlQ24F0HFjUNZ4i9+AgldJ9H6DZXi7g=="],
"radix-vue": ["radix-vue@1.9.7", "", { "dependencies": { "@floating-ui/dom": "^1.6.7", "@floating-ui/vue": "^1.1.0", "@internationalized/date": "^3.5.4", "@internationalized/number": "^3.5.3", "@tanstack/vue-virtual": "^3.8.1", "@vueuse/core": "^10.11.0", "@vueuse/shared": "^10.11.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "fast-deep-equal": "^3.1.3", "nanoid": "^5.0.7" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-1xleWzWNFPfAMmb81gu/4/MV8dXMvc7j2EIjutBpBcKwxdJfeIcQg4k9De18L2rL1/GZg5wA9KykeKTM4MjWow=="],
@@ -4048,6 +4047,8 @@
"cacheable-request/lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
"camelcase-keys/quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="],
"codemirror/@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA=="],
"codemirror/@codemirror/commands": ["@codemirror/commands@6.7.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-+cduIZ2KbesDhbykV02K25A5xIVrquSPz4UxxYBemRlAT2aW8dhwUgLDwej7q/RJUHKk4nALYcR1puecDvbdqw=="],
+34
View File
@@ -1,5 +1,39 @@
# gitbook
## 0.14.0
### Minor Changes
- 0003030: Implement AI actions dropdown
- acb9f53: New search layout
### Patch Changes
- 334cfdd: Fix responsive class for SearchInput button
- 6816f0f: add browserlists and fix old browser css-masks
- 9ee9082: Refactor icon loading state in AIAction components
- 52ab368: Reposition AI Actions dropdown
- 7212345: Fix AI Actions dropdown and LLM integration
- 8daede5: Ensure all content links are resolved relatively to preview.
- ed684c1: Move AI Actions markdown fetching to client-side
- 9cc5a78: Generalise keyboard shortcuts, add Cmd+J to AI Chat
- Updated dependencies [6816f0f]
- Updated dependencies [1738677]
- @gitbook/icons@0.2.1
- @gitbook/openapi-parser@2.2.1
- @gitbook/react-contentkit@0.7.2
- @gitbook/react-openapi@1.3.3
## 0.13.1
### Patch Changes
- f5894bc: Fix incorrect revision ID in Markdown fetch logic
- 1b59e7c: Styling improvements to AI Chat Followup questions
- Updated dependencies [bd553bc]
- @gitbook/openapi-parser@2.2.0
- @gitbook/react-openapi@1.3.2
## 0.13.0
### Minor Changes
+22
View File
@@ -415,6 +415,28 @@ const testCases: TestsCase[] = [
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
},
},
{
name: 'With sections',
url: async () => {
const data = await getSiteAPIToken('https://gitbook.com/docs');
const searchParams = new URLSearchParams();
searchParams.set('token', data.apiToken);
return `url/preview/${data.site}/?${searchParams.toString()}`;
},
screenshot: false,
run: async (page) => {
const sectionTabs = page.getByLabel('Sections');
await expect(sectionTabs).toBeVisible();
const sectionTabLinks = sectionTabs.getByRole('link');
for (const link of await sectionTabLinks.all()) {
const href = await link.getAttribute('href');
expect(href).toMatch(/^\/url\/preview\/site_p4Xo4\/?/);
}
},
},
],
},
{
@@ -16,7 +16,8 @@
"env": {
"dev": {
"vars": {
"STAGE": "dev"
"STAGE": "dev",
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
},
"r2_buckets": [
{
@@ -84,6 +85,9 @@
}
},
"staging": {
"vars": {
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
@@ -125,7 +129,8 @@
"vars": {
// This is a bit misleading, but it means that we can have 500 concurrent revalidations
// This means that we'll have up to 100 durable objects instance running at the same time
"MAX_REVALIDATE_CONCURRENCY": "100"
"MAX_REVALIDATE_CONCURRENCY": "100",
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
},
"r2_buckets": [
{
@@ -21,7 +21,8 @@
"dev": {
"vars": {
"STAGE": "dev",
"NEXT_PRIVATE_DEBUG_CACHE": "true"
"NEXT_PRIVATE_DEBUG_CACHE": "true",
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
},
"r2_buckets": [
{
@@ -85,7 +86,8 @@
"staging": {
"vars": {
"STAGE": "staging",
"WORKER_VERSION_ID": "TO_REPLACE"
"WORKER_VERSION_ID": "TO_REPLACE",
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
},
"routes": [
{
@@ -153,7 +155,8 @@
// TODO: remove this once the issue is fixed
"DEBUG_CLOUDFLARE": "true",
"WORKER_VERSION_ID": "TO_REPLACE",
"STAGE": "production"
"STAGE": "production",
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
},
"routes": [
{
+68 -95
View File
@@ -1,6 +1,4 @@
import { createHash } from 'node:crypto';
import { trace } from '@/lib/tracing';
import type {
CacheEntryType,
CacheValue,
@@ -34,41 +32,31 @@ class GitbookIncrementalCache implements IncrementalCache {
cacheType?: CacheType
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
const cacheKey = this.getR2Key(key, cacheType);
return trace(
{
operation: 'openNextIncrementalCacheGet',
name: cacheKey,
},
async (span) => {
span.setAttribute('cacheType', cacheType ?? 'cache');
const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket');
try {
// Check local cache first if available
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
if (localCacheEntry) {
span.setAttribute('cacheHit', 'local');
const result = (await localCacheEntry.json()) as WithLastModified<
CacheValue<CacheType>
>;
return this.returnNullOn404(result);
}
const r2Object = await r2.get(cacheKey);
if (!r2Object) return null;
span.setAttribute('cacheHit', 'r2');
return this.returnNullOn404({
value: await r2Object.json(),
lastModified: r2Object.uploaded.getTime(),
});
} catch (e) {
console.error('Failed to get from cache', e);
return null;
}
const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket');
try {
// Check local cache first if available
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
if (localCacheEntry) {
const result = (await localCacheEntry.json()) as WithLastModified<
CacheValue<CacheType>
>;
return this.returnNullOn404(result);
}
);
const r2Object = await r2.get(cacheKey);
if (!r2Object) return null;
return this.returnNullOn404({
value: await r2Object.json(),
lastModified: r2Object.uploaded.getTime(),
});
} catch (e) {
console.error('Failed to get from cache', e);
return null;
}
}
//TODO: This is a workaround to handle 404 responses in the cache.
@@ -89,75 +77,60 @@ class GitbookIncrementalCache implements IncrementalCache {
cacheType?: CacheType
): Promise<void> {
const cacheKey = this.getR2Key(key, cacheType);
return trace(
{
operation: 'openNextIncrementalCacheSet',
name: cacheKey,
},
async (span) => {
span.setAttribute('cacheType', cacheType ?? 'cache');
const localCache = await this.getCacheInstance();
try {
await this.writeToR2(cacheKey, JSON.stringify(value));
const localCache = await this.getCacheInstance();
//TODO: Check if there is any places where we don't have tags
// Ideally we should always have tags, but in case we don't, we need to decide how to handle it
// For now we default to a build ID tag, which allow us to invalidate the cache in case something is wrong in this deployment
const tags = this.getTagsFromCacheEntry(value) ?? [
`build_id/${process.env.NEXT_BUILD_ID}`,
];
try {
await this.writeToR2(cacheKey, JSON.stringify(value));
// We consider R2 as the source of truth, so we update the local cache
// only after a successful R2 write
await localCache.put(
this.getCacheUrlKey(cacheKey),
new Response(
JSON.stringify({
value,
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
// See https://developers.cloudflare.com/workers/reference/security-model/
lastModified: Date.now(),
}),
{
headers: {
// Cache-Control default to 30 minutes, will be overridden by `revalidate`
// In theory we should always get the `revalidate` value
'cache-control': `max-age=${value.revalidate ?? 60 * 30}`,
'cache-tag': tags.join(','),
},
}
)
);
} catch (e) {
console.error('Failed to set to cache', e);
}
}
);
//TODO: Check if there is any places where we don't have tags
// Ideally we should always have tags, but in case we don't, we need to decide how to handle it
// For now we default to a build ID tag, which allow us to invalidate the cache in case something is wrong in this deployment
const tags = this.getTagsFromCacheEntry(value) ?? [
`build_id/${process.env.NEXT_BUILD_ID}`,
];
// We consider R2 as the source of truth, so we update the local cache
// only after a successful R2 write
await localCache.put(
this.getCacheUrlKey(cacheKey),
new Response(
JSON.stringify({
value,
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
// See https://developers.cloudflare.com/workers/reference/security-model/
lastModified: Date.now(),
}),
{
headers: {
// Cache-Control default to 30 minutes, will be overridden by `revalidate`
// In theory we should always get the `revalidate` value
'cache-control': `max-age=${value.revalidate ?? 60 * 30}`,
'cache-tag': tags.join(','),
},
}
)
);
} catch (e) {
console.error('Failed to set to cache', e);
}
}
async delete(key: string): Promise<void> {
const cacheKey = this.getR2Key(key);
return trace(
{
operation: 'openNextIncrementalCacheDelete',
name: cacheKey,
},
async () => {
const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket');
try {
await r2.delete(cacheKey);
const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket');
// Here again R2 is the source of truth, so we delete from local cache first
await localCache.delete(this.getCacheUrlKey(cacheKey));
} catch (e) {
console.error('Failed to delete from cache', e);
}
}
);
try {
await r2.delete(cacheKey);
// Here again R2 is the source of truth, so we delete from local cache first
await localCache.delete(this.getCacheUrlKey(cacheKey));
} catch (e) {
console.error('Failed to delete from cache', e);
}
}
async writeToR2(key: string, value: string): Promise<void> {
@@ -1,4 +1,3 @@
import { trace } from '@/lib/tracing';
import type { Queue } from '@opennextjs/aws/types/overrides.js';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
@@ -6,9 +5,7 @@ import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
export default {
name: 'GitbookISRQueue',
send: async (msg) => {
return trace({ operation: 'gitbookISRQueueSend', name: msg.MessageBody.url }, async () => {
const { ctx } = getCloudflareContext();
ctx.waitUntil(doQueue.send(msg));
});
const { ctx } = getCloudflareContext();
ctx.waitUntil(doQueue.send(msg));
},
} satisfies Queue;
+3 -1
View File
@@ -1,9 +1,11 @@
import { getLogger } from '@/lib/logger';
import type { Queue } from '@opennextjs/aws/types/overrides.js';
export default {
name: 'GitbookISRQueue',
send: async (msg) => {
const logger = getLogger().subLogger('GitbookISRQueue');
// We should never reach this point in the server. If that's the case, we should log it.
console.warn('GitbookISRQueue: send called on server side, this should not happen.', msg);
logger.warn('send called on server side, this should not happen.', msg);
},
} satisfies Queue;
@@ -1,4 +1,4 @@
import { trace } from '@/lib/tracing';
import { getLogger } from '@/lib/logger';
import type { NextModeTagCache } from '@opennextjs/aws/types/overrides.js';
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
@@ -23,59 +23,27 @@ export default {
getLastRevalidated: async (tags: string[]) => {
const tagsToCheck = tags.filter(softTagFilter);
if (tagsToCheck.length === 0) {
// If we reach here, it probably means that there is an issue that we'll need to address.
console.warn(
'getLastRevalidated - No valid tags to check for last revalidation, original tags:',
tags
);
return 0; // If no tags to check, return 0
}
return trace(
{
operation: 'gitbookTagCacheGetLastRevalidated',
name: tagsToCheck.join(', '),
},
async () => {
return await originalTagCache.getLastRevalidated(tagsToCheck);
}
);
return await originalTagCache.getLastRevalidated(tagsToCheck);
},
hasBeenRevalidated: async (tags: string[], lastModified?: number) => {
const tagsToCheck = tags.filter(softTagFilter);
if (tagsToCheck.length === 0) {
// If we reach here, it probably means that there is an issue that we'll need to address.
console.warn(
'hasBeenRevalidated - No valid tags to check for revalidation, original tags:',
tags
);
return false; // If no tags to check, return false
}
return trace(
{
operation: 'gitbookTagCacheHasBeenRevalidated',
name: tagsToCheck.join(', '),
},
async () => {
const result = await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified);
return result;
}
);
return await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified);
},
writeTags: async (tags: string[]) => {
return trace(
{
operation: 'gitbookTagCacheWriteTags',
name: tags.join(', '),
},
async () => {
const tagsToWrite = tags.filter(softTagFilter);
if (tagsToWrite.length === 0) {
console.warn('writeTags - No valid tags to write');
return; // If no tags to write, exit early
}
// Write only the filtered tags
await originalTagCache.writeTags(tagsToWrite);
}
);
const tagsToWrite = tags.filter(softTagFilter);
if (tagsToWrite.length === 0) {
const logger = getLogger().subLogger('gitbookTagCache');
logger.warn('writeTags - No valid tags to write');
return; // If no tags to write, exit early
}
// Write only the filtered tags
await originalTagCache.writeTags(tagsToWrite);
},
} satisfies NextModeTagCache;
+24 -22
View File
@@ -1,75 +1,74 @@
{
"name": "gitbook",
"version": "0.13.0",
"version": "0.14.0",
"private": true,
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/cache-tags": "workspace:*",
"@opennextjs/cloudflare": "1.2.1",
"@sindresorhus/fnv1a": "^3.1.0",
"assert-never": "^1.2.1",
"jwt-decode": "^4.0.0",
"next": "^15.3.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"warn-once": "^0.1.1",
"object-identity": "^0.1.2",
"@gitbook/colors": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*",
"@gitbook/fonts": "workspace:*",
"@gitbook/icons": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
"@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*",
"@opennextjs/cloudflare": "^1.4.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-tooltip": "^1.1.8",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16",
"ai": "^4.2.2",
"assert-never": "^1.2.1",
"bun-types": "^1.1.20",
"classnames": "^2.5.1",
"direction": "^2.0.1",
"event-iterator": "^2.0.0",
"framer-motion": "^10.16.14",
"image-size": "^2.0.2",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0",
"katex": "^0.16.9",
"mathjax": "^3.2.2",
"mdast-util-to-markdown": "^2.1.2",
"mdast-util-from-markdown": "^2.0.2",
"mdast-util-frontmatter": "^2.0.1",
"mdast-util-gfm": "^3.1.0",
"micromark-extension-gfm": "^3.0.0",
"micromark-extension-frontmatter": "^2.0.0",
"unist-util-remove": "^4.0.0",
"unist-util-visit": "^5.0.0",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.17",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"next": "^15.3.2",
"next-themes": "^0.2.1",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
"object-identity": "^0.1.2",
"openapi-types": "^12.1.3",
"p-map": "^7.0.3",
"parse-cache-control": "^1.0.1",
"@tusbar/cache-control": "^1.0.2",
"react-hotkeys-hook": "^4.4.1",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^3.2.0",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.5",
"unist-util-remove": "^4.0.0",
"unist-util-visit": "^5.0.0",
"url-join": "^5.0.0",
"usehooks-ts": "^3.1.0",
"warn-once": "^0.1.1",
"zustand": "^5.0.3",
"image-size": "^2.0.2",
"direction": "^2.0.1"
"quick-lru": "^7.0.1"
},
"devDependencies": {
"@argos-ci/playwright": "^5.0.5",
@@ -112,5 +111,8 @@
"e2e-customers": "playwright test e2e/customers.spec.ts",
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
"typecheck": "tsc --noEmit"
}
},
"browserslist": [
">0.3%, chrome >= 64, edge >= 79, firefox >= 67, opera >= 51, safari >= 12 and not dead"
]
}
@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server';
import { getLogger } from '@/lib/logger';
import { withVerifySignature } from '@/lib/routes';
import { revalidateTag } from 'next/cache';
@@ -12,6 +13,7 @@ interface JsonBody {
* The body should be a JSON with { tags: string[] }
*/
export async function POST(req: NextRequest) {
const logger = getLogger().subLogger('revalidate');
return withVerifySignature<JsonBody>(req, async (body) => {
if (!body.tags || !Array.isArray(body.tags)) {
return NextResponse.json(
@@ -23,8 +25,7 @@ export async function POST(req: NextRequest) {
}
body.tags.forEach((tag) => {
// biome-ignore lint/suspicious/noConsole: we want to log here
console.log(`Revalidating tag: ${tag}`);
logger.log(`Revalidating tag: ${tag}`);
revalidateTag(tag);
});
@@ -0,0 +1,293 @@
'use client';
import { useAIChatController } from '@/components/AI/useAIChat';
import { useAIChatState } from '@/components/AI/useAIChat';
import { ChatGPTIcon } from '@/components/AIActions/assets/ChatGPTIcon';
import { ClaudeIcon } from '@/components/AIActions/assets/ClaudeIcon';
import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon';
import { getAIChatName } from '@/components/AIChat';
import { AIChatIcon } from '@/components/AIChat';
import { Button } from '@/components/primitives/Button';
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
import { tString, useLanguage } from '@/intl/client';
import type { TranslationLanguage } from '@/intl/translations';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import assertNever from 'assert-never';
import QuickLRU from 'quick-lru';
import type React from 'react';
import { create } from 'zustand';
type AIActionType = 'button' | 'dropdown-menu-item';
/**
* Opens our AI Docs Assistant.
*/
export function OpenDocsAssistant(props: { type: AIActionType; trademark: boolean }) {
const { type, trademark } = props;
const chatController = useAIChatController();
const chat = useAIChatState();
const language = useLanguage();
return (
<AIActionWrapper
type={type}
icon={
<AIChatIcon state={chat.loading ? 'thinking' : 'default'} trademark={trademark} />
}
label={tString(language, 'ai_chat_ask', getAIChatName(trademark))}
shortLabel={tString(language, 'ask')}
description={tString(language, 'ai_chat_ask_about_page', getAIChatName(trademark))}
disabled={chat.loading}
onClick={() => {
// Open the chat if it's not already open
if (!chat.opened) {
chatController.open();
}
// Send the "What is this page about?" message
chatController.postMessage({
message: tString(language, 'ai_chat_suggested_questions_about_this_page'),
});
}}
/>
);
}
type CopiedStore = {
copied: boolean;
loading: boolean;
};
// We need to store everything in a store to share the state between every instance of the component.
const useCopiedStore = create<
CopiedStore & {
setLoading: (loading: boolean) => void;
copy: (data: string, opts?: { onSuccess?: () => void }) => void;
}
>((set) => {
let timeoutRef: ReturnType<typeof setTimeout> | null = null;
return {
copied: false,
loading: false,
setLoading: (loading: boolean) => set({ loading }),
copy: async (data, opts) => {
const { onSuccess } = opts || {};
if (timeoutRef) {
clearTimeout(timeoutRef);
}
await navigator.clipboard.writeText(data);
set({ copied: true });
timeoutRef = setTimeout(() => {
set({ copied: false });
onSuccess?.();
// Reset the timeout ref to avoid multiple timeouts
timeoutRef = null;
}, 1500);
},
};
});
/**
* Cache for the markdown versbion of the page.
*/
const markdownCache = new QuickLRU<string, string>({ maxSize: 10 });
/**
* Copies the markdown version of the page to the clipboard.
*/
export function CopyMarkdown(props: {
markdownPageUrl: string;
type: AIActionType;
isDefaultAction?: boolean;
}) {
const { markdownPageUrl, type, isDefaultAction } = props;
const language = useLanguage();
const closeDropdown = useDropdownMenuClose();
const { copied, loading, setLoading, copy } = useCopiedStore();
// Fetch the markdown from the page
const fetchMarkdown = async () => {
setLoading(true);
const result = await fetch(markdownPageUrl).then((res) => res.text());
markdownCache.set(markdownPageUrl, result);
setLoading(false);
return result;
};
const onClick = async (e: React.MouseEvent) => {
// Prevent default behavior for non-default actions to avoid closing the dropdown.
// This allows showing transient UI (e.g., a "copied" state) inside the menu item.
if (!isDefaultAction) {
e.preventDefault();
}
copy(markdownCache.get(markdownPageUrl) || (await fetchMarkdown()), {
onSuccess: () => {
// We close the dropdown menu if the action is a dropdown menu item and not the default action.
if (type === 'dropdown-menu-item' && !isDefaultAction) {
closeDropdown();
}
},
});
};
return (
<AIActionWrapper
type={type}
icon={copied ? 'check' : 'copy'}
label={copied ? tString(language, 'code_copied') : tString(language, 'copy_page')}
shortLabel={copied ? tString(language, 'code_copied') : tString(language, 'code_copy')}
description={tString(language, 'copy_page_markdown')}
onClick={onClick}
loading={loading}
/>
);
}
/**
* Redirects to the markdown version of the page.
*/
export function ViewAsMarkdown(props: { markdownPageUrl: string; type: AIActionType }) {
const { markdownPageUrl, type } = props;
const language = useLanguage();
return (
<AIActionWrapper
type={type}
icon={<MarkdownIcon className="size-4 fill-current" />}
label={tString(language, 'view_page_markdown')}
description={tString(language, 'view_page_plaintext')}
href={markdownPageUrl}
/>
);
}
/**
* Open the page in a LLM with a pre-filled prompt. Either ChatGPT or Claude.
*/
export function OpenInLLM(props: {
provider: 'chatgpt' | 'claude';
url: string;
type: AIActionType;
}) {
const { provider, url, type } = props;
const language = useLanguage();
const providerLabel = provider === 'chatgpt' ? 'ChatGPT' : 'Claude';
return (
<AIActionWrapper
type={type}
icon={
provider === 'chatgpt' ? (
<ChatGPTIcon className="size-3.5 fill-current" />
) : (
<ClaudeIcon className="size-3.5 fill-current" />
)
}
label={tString(language, 'open_in', providerLabel)}
shortLabel={providerLabel}
description={tString(language, 'ai_chat_ask_about_page', providerLabel)}
href={getLLMURL(provider, url, language)}
/>
);
}
/**
* Wraps an action in a button (for the default action) or dropdown menu item.
*/
function AIActionWrapper(props: {
type: AIActionType;
icon: IconName | React.ReactNode;
label: string;
/**
* The label to display in the button. If not provided, the `label` will be used.
*/
shortLabel?: string;
onClick?: (e: React.MouseEvent) => void;
description?: string;
href?: string;
disabled?: boolean;
loading?: boolean;
}) {
const { type, icon, label, shortLabel, onClick, href, description, disabled, loading } = props;
if (type === 'button') {
return (
<Button
icon={
loading ? <Icon icon="spinner-third" className="size-4 animate-spin" /> : icon
}
size="xsmall"
variant="secondary"
label={shortLabel || label}
className="hover:!scale-100 !shadow-none !rounded-r-none hover:!translate-y-0 border-r-0 bg-tint-base text-sm"
onClick={onClick}
href={href}
target={href ? '_blank' : undefined}
disabled={disabled || loading}
/>
);
}
return (
<DropdownMenuItem
className="flex items-stretch gap-2.5 p-2"
href={href}
target="_blank"
onClick={onClick}
disabled={disabled || loading}
>
<div className="flex size-5 items-center justify-center text-tint">
{loading ? (
<Icon icon="spinner-third" className="size-4 animate-spin" />
) : icon ? (
typeof icon === 'string' ? (
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Regular}
className="size-4 fill-transparent stroke-current"
/>
) : (
icon
)
) : null}
</div>
<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}
</span>
{description && <span className="truncate text-tint text-xs">{description}</span>}
</div>
</DropdownMenuItem>
);
}
/**
* Returns the URL to open the page in a LLM with a pre-filled prompt.
*/
function getLLMURL(provider: 'chatgpt' | 'claude', url: string, language: TranslationLanguage) {
const prompt = encodeURIComponent(tString(language, 'open_in_llms_pre_prompt', url));
switch (provider) {
case 'chatgpt':
return `https://chat.openai.com/?q=${prompt}`;
case 'claude':
return `https://claude.ai/new?q=${prompt}`;
default:
assertNever(provider);
}
}
@@ -0,0 +1,104 @@
'use client';
import {
CopyMarkdown,
OpenDocsAssistant,
OpenInLLM,
ViewAsMarkdown,
} from '@/components/AIActions/AIActions';
import { Button } from '@/components/primitives/Button';
import { DropdownMenu } from '@/components/primitives/DropdownMenu';
import { Icon } from '@gitbook/icons';
import { useRef } from 'react';
interface AIActionsDropdownProps {
markdownPageUrl: string;
withAIChat?: boolean;
trademark: boolean;
/**
* Whether to include the "Open in LLM" entries in the dropdown menu.
*/
withLLMActions?: boolean;
}
/**
* Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM).
*/
export function AIActionsDropdown(props: AIActionsDropdownProps) {
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref} className="flex h-fit items-stretch justify-start">
<DefaultAction {...props} />
<DropdownMenu
align="end"
className="!min-w-60 max-w-max"
button={
<Button
icon={
<Icon
icon="chevron-down"
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
/>
}
iconOnly
size="xsmall"
variant="secondary"
className="hover:!scale-100 hover:!translate-y-0 !shadow-none !rounded-l-none bg-tint-base text-sm"
/>
}
>
<AIActionsDropdownMenuContent {...props} />
</DropdownMenu>
</div>
);
}
/**
* The content of the dropdown menu.
*/
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
const { markdownPageUrl, withAIChat, trademark, withLLMActions } = props;
return (
<>
{withAIChat ? (
<OpenDocsAssistant trademark={trademark} type="dropdown-menu-item" />
) : null}
<CopyMarkdown
isDefaultAction={!withAIChat}
markdownPageUrl={markdownPageUrl}
type="dropdown-menu-item"
/>
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
{withLLMActions ? (
<>
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="dropdown-menu-item" />
<OpenInLLM provider="claude" url={markdownPageUrl} type="dropdown-menu-item" />
</>
) : null}
</>
);
}
/**
* A default action shown as a quick-access button beside the dropdown menu
*/
function DefaultAction(props: AIActionsDropdownProps) {
const { markdownPageUrl, withAIChat, trademark } = props;
if (withAIChat) {
return <OpenDocsAssistant trademark={trademark} type="button" />;
}
return (
<CopyMarkdown
isDefaultAction={!withAIChat}
markdownPageUrl={markdownPageUrl}
type="button"
/>
);
}
@@ -0,0 +1,8 @@
export function ChatGPTIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" {...props}>
<title>ChatGPT</title>
<path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z" />
</svg>
);
}
@@ -0,0 +1,19 @@
export function ClaudeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 256 257"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
{...props}
>
<title>Claude</title>
<g>
<path
d="M50.2278481,170.321013 L100.585316,142.063797 L101.427848,139.601013 L100.585316,138.24 L98.1225316,138.24 L89.6972152,137.721519 L60.921519,136.943797 L35.9696203,135.906835 L11.795443,134.610633 L5.70329114,133.31443 L0,125.796456 L0.583291139,122.037468 L5.70329114,118.602532 L13.0268354,119.250633 L29.2293671,120.352405 L53.5331646,122.037468 L71.161519,123.07443 L97.28,125.796456 L101.427848,125.796456 L102.011139,124.111392 L100.585316,123.07443 L99.4835443,122.037468 L74.3372152,104.992405 L47.116962,86.9751899 L32.8587342,76.6055696 L25.1463291,71.3559494 L21.2577215,66.4303797 L19.5726582,55.6718987 L26.5721519,47.9594937 L35.9696203,48.6075949 L38.3675949,49.2556962 L47.8946835,56.5792405 L68.2450633,72.3281013 L94.8172152,91.9007595 L98.7058228,95.1412658 L100.261266,94.0394937 L100.455696,93.2617722 L98.7058228,90.3453165 L84.2531646,64.2268354 L68.8283544,37.6546835 L61.958481,26.636962 L60.1437975,20.0263291 C59.4956962,17.3043038 59.0420253,15.0359494 59.0420253,12.2491139 L67.0136709,1.42582278 L71.4207595,-1.42108547e-14 L82.0496203,1.42582278 L86.521519,5.31443038 L93.1321519,20.4151899 L103.825823,44.2005063 L120.417215,76.5407595 L125.277975,86.1326582 L127.87038,95.0116456 L128.842532,97.7336709 L130.527595,97.7336709 L130.527595,96.1782278 L131.888608,77.9665823 L134.416203,55.6070886 L136.878987,26.8313924 L137.721519,18.7301266 L141.739747,9.00860759 L149.711392,3.75898734 L155.933165,6.74025316 L161.053165,14.0637975 L160.340253,18.7949367 L157.294177,38.5620253 L151.331646,69.5412658 L147.443038,90.2805063 L149.711392,90.2805063 L152.303797,87.6881013 L162.803038,73.7539241 L180.431392,51.718481 L188.208608,42.9691139 L197.282025,33.3124051 L203.114937,28.7108861 L214.132658,28.7108861 L222.233924,40.7655696 L218.604557,53.2091139 L207.262785,67.596962 L197.865316,79.7812658 L184.38481,97.9281013 L175.959494,112.44557 L176.737215,113.612152 L178.746329,113.417722 L209.207089,106.936709 L225.668861,103.955443 L245.306329,100.585316 L254.185316,104.733165 L255.157468,108.945823 L251.657722,117.56557 L230.659241,122.75038 L206.031392,127.675949 L169.348861,136.360506 L168.89519,136.684557 L169.413671,137.332658 L185.940253,138.888101 L193.004557,139.276962 L210.308861,139.276962 L242.519494,141.674937 L250.94481,147.248608 L256,154.053671 L255.157468,159.238481 L242.195443,165.849114 L224.696709,161.701266 L183.866329,151.979747 L169.867342,148.48 L167.923038,148.48 L167.923038,149.646582 L179.588861,161.053165 L200.976203,180.366582 L227.742785,205.253671 L229.103797,211.410633 L225.668861,216.271392 L222.039494,215.752911 L198.513418,198.059747 L189.44,190.088101 L168.89519,172.783797 L167.534177,172.783797 L167.534177,174.598481 L172.265316,181.533165 L197.282025,219.123038 L198.578228,230.659241 L196.763544,234.418228 L190.282532,236.686582 L183.153418,235.39038 L168.506329,214.84557 L153.40557,191.708354 L141.221266,170.969114 L139.730633,171.811646 L132.536709,249.259747 L129.166582,253.213165 L121.389367,256.19443 L114.908354,251.268861 L111.473418,243.297215 L114.908354,227.548354 L119.056203,207.003544 L122.426329,190.671392 L125.472405,170.385823 L127.287089,163.64557 L127.157468,163.191899 L125.666835,163.386329 L110.371646,184.38481 L87.1048101,215.817722 L68.6987342,235.52 L64.2916456,237.269873 L56.6440506,233.316456 L57.356962,226.252152 L61.6344304,219.96557 L87.1048101,187.560506 L102.46481,167.469367 L112.380759,155.868354 L112.315949,154.183291 L111.732658,154.183291 L44.0708861,198.124557 L32.0162025,199.68 L26.8313924,194.819241 L27.4794937,186.847595 L29.9422785,184.25519 L50.2926582,170.256203 L50.2278481,170.321013 Z"
fill="currentColor"
/>
</g>
</svg>
);
}
@@ -0,0 +1,9 @@
export function MarkdownIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 471 289.85" {...props}>
<title>Markdown</title>
<path d="M437,289.85H34a34,34,0,0,1-34-34V34A34,34,0,0,1,34,0H437a34,34,0,0,1,34,34V255.88A34,34,0,0,1,437,289.85ZM34,22.64A11.34,11.34,0,0,0,22.64,34V255.88A11.34,11.34,0,0,0,34,267.2H437a11.34,11.34,0,0,0,11.33-11.32V34A11.34,11.34,0,0,0,437,22.64Z" />
<path d="M67.93,221.91v-154h45.29l45.29,56.61L203.8,67.93h45.29v154H203.8V133.6l-45.29,56.61L113.22,133.6v88.31Zm283.06,0-67.94-74.72h45.29V67.93h45.29v79.26h45.29Z" />
</svg>
);
}
@@ -3,6 +3,7 @@
import { t, tString, useLanguage } from '@/intl/client';
import { Icon } from '@gitbook/icons';
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import {
type AIChatController,
type AIChatState,
@@ -12,27 +13,48 @@ import {
import { useNow } from '../hooks';
import { Button } from '../primitives';
import { DropdownMenu, DropdownMenuItem } from '../primitives/DropdownMenu';
import AIChatIcon from './AIChatIcon';
import { AIChatIcon } from './AIChatIcon';
import { AIChatInput } from './AIChatInput';
import { AIChatMessages } from './AIChatMessages';
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
export function AIChat() {
export function AIChat(props: { trademark: boolean }) {
const { trademark } = props;
const chat = useAIChatState();
const chatController = useAIChatController();
useHotkeys(
'mod+j',
(e) => {
e.preventDefault();
chatController.open();
},
[]
);
useHotkeys(
'esc',
() => {
chatController.close();
},
[]
);
if (!chat.opened) {
return null;
}
return <AIChatWindow chat={chat} />;
return <AIChatWindow trademark={trademark} chatController={chatController} chat={chat} />;
}
export function AIChatWindow(props: { chat: AIChatState }) {
const { chat } = props;
export function AIChatWindow(props: {
chatController: AIChatController;
chat: AIChatState;
trademark: boolean;
}) {
const { chatController, chat, trademark } = props;
const [input, setInput] = React.useState('');
const chatController = useAIChatController();
const containerRef = React.useRef<HTMLDivElement>(null);
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
@@ -86,17 +108,28 @@ export function AIChatWindow(props: { chat: AIChatState }) {
return (
<div
className="ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl animate-present px-4 py-4 transition-all duration-300 sm:px-6 lg:fixed lg:w-80 lg:animate-enterFromRight lg:pr-4 lg:pl-0 xl:w-96"
className="ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl animate-present scroll-mt-36 px-4 py-4 transition-all duration-300 sm:px-6 lg:fixed lg:w-80 lg:animate-enterFromRight lg:pr-4 lg:pl-0 xl:w-96"
ref={containerRef}
>
<div className="relative flex h-full grow flex-col overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-md bg-tint-base text-sm text-tint depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
<div className="flex items-center gap-2 border-tint-subtle border-b bg-tint-subtle px-4 py-2 text-tint-strong">
<AIChatIcon
className={`size-5 text-tint ${chat.loading ? 'animate-pulse' : ''}`}
state={chat.loading ? 'thinking' : 'default'}
className="size-5 text-tint"
trademark={trademark}
state={
chat.error
? 'error'
: chat.loading
? chat.messages[chat.messages.length - 1].content
? 'working'
: 'thinking'
: chat.messages.length > 0
? 'done'
: 'default'
}
/>
<div className="flex flex-col">
<div className="font-bold">Docs Assistant</div>
<div className="font-bold">{getAIChatName(trademark)}</div>
<div
className={`text-tint text-xs leading-none transition-all duration-500 ${
chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0'
@@ -154,7 +187,11 @@ export function AIChatWindow(props: { chat: AIChatState }) {
{isEmpty ? (
<div className="flex min-h-full w-full shrink-0 flex-col items-center justify-center gap-6 py-4">
<div className="flex size-32 animate-[fadeIn_500ms_both] items-center justify-center rounded-full bg-tint-subtle">
<AIChatIcon className="size-16 animate-[present_500ms_200ms_both]" />
<AIChatIcon
state="intro"
trademark={trademark}
className="size-16 animate-[present_500ms_200ms_both]"
/>
</div>
<div className="animate-[fadeIn_500ms_400ms_both]">
<h5 className=" text-center font-bold text-lg text-tint-strong">
@@ -169,7 +206,11 @@ export function AIChatWindow(props: { chat: AIChatState }) {
) : null}
</div>
) : (
<AIChatMessages chat={chat} lastUserMessageRef={lastUserMessageRef} />
<AIChatMessages
chat={chat}
chatController={chatController}
lastUserMessageRef={lastUserMessageRef}
/>
)}
</div>
<div
@@ -177,11 +218,7 @@ export function AIChatWindow(props: { chat: AIChatState }) {
className="absolute inset-x-0 bottom-0 mr-2 flex flex-col gap-4 bg-gradient-to-b from-transparent to-50% to-tint-base/9 p-4 pr-2"
>
{/* Display an error banner when something went wrong. */}
{chat.error ? (
<AIChatError chatController={chatController} />
) : (
<AIChatFollowupSuggestions chat={chat} chatController={chatController} />
)}
{chat.error ? <AIChatError chatController={chatController} /> : null}
<AIChatInput
value={input}
@@ -224,3 +261,11 @@ function AIChatError(props: { chatController: AIChatController }) {
</div>
);
}
export function getAIChatName(trademark: boolean) {
const language = useLanguage();
return trademark
? tString(language, 'ai_chat_assistant_name')
: tString(language, 'ai_chat_assistant_name_unbranded');
}
@@ -1,26 +1,32 @@
'use client';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
import { Button } from '../primitives';
import AIChatIcon from './AIChatIcon';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
import { getAIChatName } from './AIChat';
import { AIChatIcon } from './AIChatIcon';
/**
* Button to open/close the AI chat.
*/
export function AIChatButton() {
export function AIChatButton(props: { trademark: boolean }) {
const { trademark } = props;
const chatController = useAIChatController();
const chat = useAIChatState();
const language = useLanguage();
return (
<Button
icon={<AIChatIcon />}
icon={<AIChatIcon trademark={trademark} />}
iconOnly
size="default"
variant="secondary"
className="!px-3 bg-tint-base py-2.5"
label={tString(language, 'ai_chat_assistant_name')}
variant="header"
className={tcls('h-9 px-2.5')}
label={
<div className="flex items-center gap-2">
{getAIChatName(trademark)}
<KeyboardShortcut keys={['mod', 'j']} className="border-tint-11 text-tint-1" />
</div>
}
onClick={() => {
if (chat.opened) {
chatController.close();
@@ -1,64 +1,150 @@
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import type React from 'react';
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
className?: string;
size?: number;
state?: 'thinking' | 'default';
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error';
trademark?: boolean;
}
const AIChatIcon = ({
export const AIChatIcon = ({
className = 'size-4',
size,
trademark = true,
state = 'default',
...props
}: AIChatIconProps) => {
if (!trademark) {
return (
<Icon
icon="sparkle"
className={tcls(
className,
(state === 'thinking' || state === 'working') &&
'animate-[spin_2s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
state === 'intro' && 'animate-[spin_2s_forwards_cubic-bezier(0.16,1,0.3,1)]'
)}
/>
);
}
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
preserveAspectRatio="xMaxYMid meet"
className={className}
aria-busy={state === 'thinking'}
{...props}
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
{...(size && { width: size, height: size })}
{...props}
>
<title>Docs Assistant</title>
<title>GitBook Assistant</title>
{/* Sparkle */}
<path
d="M12.8916 1.06265C12.921 0.979101 13.0392 0.979127 13.0685 1.06267C13.239 1.5478 13.3439 1.84646 13.516 2.1032C13.6683 2.33042 13.8578 2.53033 14.0766 2.6945C14.3239 2.88 14.6165 3.00068 15.0919 3.19671C15.1761 3.23142 15.1761 3.3506 15.0919 3.38531C14.6165 3.58134 14.3239 3.70203 14.0766 3.88752C13.8578 4.05169 13.6683 4.2516 13.516 4.47882C13.3439 4.73556 13.239 5.03423 13.0685 5.51937C13.0392 5.60291 12.921 5.60292 12.8916 5.51938C12.7212 5.03423 12.6162 4.73557 12.4442 4.47882C12.2919 4.2516 12.1023 4.05169 11.8835 3.88752C11.6363 3.70202 11.3436 3.58134 10.8682 3.38531C10.7841 3.3506 10.7841 3.23141 10.8683 3.1967C11.3436 3.00067 11.6363 2.87999 11.8835 2.6945C12.1023 2.53033 12.2919 2.33042 12.4442 2.1032C12.6162 1.84646 12.7212 1.54779 12.8916 1.06265Z"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
className={state === 'thinking' ? 'animate-spin' : ''}
className={tcls(
state === 'intro' &&
'animate-[fadeIn_.5s_.7s_both,spin_2s_1s_forwards_cubic-bezier(.43,1.54,.64,1)]',
(state === 'working' || state === 'thinking') &&
'animate-[fadeIn_.5s_.3s_both,spin_2s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
state === 'done' && 'animate-[fadeOut_.5s_both]',
state === 'default' && 'animate-[fadeIn_0s_both]',
state === 'error' && 'hidden'
)}
style={{ transformOrigin: '13px 3.5px' }}
/>
{/* Error */}
<g
clipPath="url(#clip0_153_2034)"
className={tcls(
'text-danger-subtle',
state === 'error' ? 'animate-[fadeIn_.5s_.3s_both]' : 'hidden'
)}
>
<path
d="M13.0312 1.42059L13.0312 3.95184"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
<path
d="M13.0312 6.00253V6.00263"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</g>
{/* Check */}
<path
d="M9.479 10.2734C9.479 11.2369 8.6115 11.2578 7.54138 11.2578C6.47126 11.2578 5.60376 11.2369 5.60376 10.2734"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
<path
d="M9.47913 6.77344L9.47913 7.77344"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
<path
d="M5.60388 6.77344L5.60388 7.77344"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
<path
d="M13.2813 8.875L13.2813 10.125C13.2813 12.3341 11.4904 14.125 9.28125 14.125L1.80165 14.125L1.80165 6.78125C1.80165 4.57211 3.59251 2.78125 5.80165 2.78125L7.67889 2.78125"
d="M10.8051 3.71161L12.2401 5.27411L14.823 2.14911"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
className={tcls(
state === 'done'
? 'animate-[fadeIn_.5s_.3s_both]'
: 'animate-[fadeOut_.5s_both]',
state === 'intro' && 'hidden'
)}
/>
{/* Background */}
<path
d="M3.5625 8.78512L7.26347 10.9219C7.88227 11.2791 8.64467 11.2791 9.26347 10.9219L14.25 8.0429C14.5833 7.85045 15 8.09101 15 8.47591V10.2777C15 10.4563 14.9047 10.6214 14.75 10.7107L9.26347 13.8784C8.64467 14.2356 7.88228 14.2356 7.26347 13.8784L3.5625 11.7416C2.70833 11.2978 1 9.93199 1 8.01949M1 8.01949C1 6.6448 1.84765 5.98698 2.62903 5.71701C3.15426 5.53555 3.71577 5.70568 4.19701 5.98353L7.26347 7.75395C7.88228 8.11122 8.64467 8.11122 9.26347 7.75395L10.9095 6.80362M1 8.01949C1 6.4945 2.03973 5.30731 2.5596 4.90434L7.37937 2.12165C7.79013 1.88449 8.26417 1.80476 8.71747 1.88245"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
className={tcls(state === 'intro' && 'animate-[fadeIn_2s_forwards]')}
/>
{/* Logo */}
<mask
id="mask0_220_25"
style={{ maskType: 'alpha' }}
maskUnits="userSpaceOnUse"
x="0"
y="1"
width="16"
height="14"
>
<path
d="M3.5625 8.78512L7.26347 10.9219C7.88227 11.2791 8.64467 11.2791 9.26347 10.9219L14.25 8.0429C14.5833 7.85045 15 8.09101 15 8.47591V10.2777C15 10.4563 14.9047 10.6214 14.75 10.7107L9.26347 13.8784C8.64467 14.2356 7.88228 14.2356 7.26347 13.8784L3.5625 11.7416C2.70833 11.2978 1 9.93199 1 8.01949M1 8.01949C1 6.6448 1.84765 5.98698 2.62903 5.71701C3.15426 5.53555 3.71577 5.70568 4.19701 5.98353L7.26347 7.75395C7.88228 8.11122 8.64467 8.11122 9.26347 7.75395L10.9095 6.80362M1 8.01949C1 6.4945 2.03973 5.30731 2.5596 4.90434L7.37937 2.12165C7.79013 1.88449 8.26417 1.80476 8.71747 1.88245"
stroke="currentColor"
pathLength="100"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</mask>
<g mask="url(#mask0_220_25)">
<path
d="M3.5625 8.78512L7.26347 10.9219C7.88227 11.2791 8.64467 11.2791 9.26347 10.9219L14.25 8.0429C14.5833 7.85045 15 8.09101 15 8.47591V10.2777C15 10.4563 14.9047 10.6214 14.75 10.7107L9.26347 13.8784C8.64467 14.2356 7.88228 14.2356 7.26347 13.8784L3.5625 11.7416C2.70833 11.2978 1 9.93199 1 8.01949C1 6.6448 1.84765 5.98698 2.62903 5.71701C3.15426 5.53555 3.71577 5.70568 4.19701 5.98353L7.26347 7.75395C7.88228 8.11122 8.64467 8.11122 9.26347 7.75395L14.1991 4.90434L9.37937 2.12165C8.76057 1.76438 7.99907 1.76386 7.38027 2.12113C5.89314 2.97972 3.20298 4.53289 2.5596 4.90434C1.77376 5.35804 1 6.11597 1.00148 7.9207"
stroke="currentColor"
pathLength="100"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
className={tcls(
(state === 'thinking' || state === 'working') &&
'animate-[pathLoading_2s_infinite_both]',
state === 'intro' && 'animate-[pathEnter_2s_both]',
state === 'done' && 'animate-[pathEnter_1s_forwards_ease]'
)}
/>
</g>
</svg>
);
};
export default AIChatIcon;
@@ -2,7 +2,9 @@ import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { useEffect, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { Button } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
import { Tooltip } from '../primitives/Tooltip';
export function AIChatInput(props: {
@@ -31,12 +33,25 @@ export function AIChatInput(props: {
};
useEffect(() => {
if (!disabled) {
setTimeout(() => {
if (!disabled && !loading) {
// Add a small delay to ensure the input is rendered before focusing
// This fixes inconsistent focus behaviour across browsers
const timeout = setTimeout(() => {
inputRef.current?.focus();
}, 300);
}, 50);
return () => clearTimeout(timeout);
}
}, [disabled]);
}, [disabled, loading]);
useHotkeys(
'mod+j',
(e) => {
e.preventDefault();
inputRef.current?.focus();
},
[]
);
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">
@@ -54,6 +69,7 @@ export function AIChatInput(props: {
'pb-12',
'h-auto',
'bg-transparent',
'peer',
'max-h-64',
'placeholder:text-tint/8',
'transition-colors',
@@ -69,6 +85,12 @@ export function AIChatInput(props: {
placeholder={tString(language, 'ai_chat_input_placeholder')}
onChange={handleInput}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.currentTarget.blur();
return;
}
if (event.key === 'Enter' && !event.shiftKey && value.trim()) {
event.preventDefault();
event.currentTarget.style.height = 'auto';
@@ -76,6 +98,9 @@ export function AIChatInput(props: {
}
}}
/>
<div className="absolute top-2.5 right-4 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
<KeyboardShortcut keys={['mod', 'j']} />
</div>
<div className="absolute inset-x-0 bottom-0 flex items-center px-2 py-2">
<Tooltip
label={
@@ -101,8 +126,8 @@ 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">
<span className="-ml-1 circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-11/7 px-1 py-0.5 font-mono font-semibold text-[0.65rem] text-contrast-tint-11 leading-none">
AI
<span className="-ml-1 rounded-corners:rounded circular-corners:rounded-2xl 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>
</div>
@@ -1,17 +1,20 @@
import { tcls } from '@/lib/tailwind';
import { AIMessageRole } from '@gitbook/api';
import type React from 'react';
import type { AIChatState } from '../AI/useAIChat';
import type { AIChatController, AIChatState } from '../AI/useAIChat';
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
export function AIChatMessages(props: {
chat: AIChatState;
chatController: AIChatController;
lastUserMessageRef?: React.RefObject<HTMLDivElement>;
}) {
const { chat, lastUserMessageRef } = props;
const { chat, chatController, lastUserMessageRef } = props;
return (
<>
{chat.messages.map((message, index) => {
const isLastMessage = index === chat.messages.length - 1;
const isLastUserMessage =
message.role === AIMessageRole.User &&
index === chat.messages.map((m) => m.role).lastIndexOf(AIMessageRole.User);
@@ -23,8 +26,11 @@ export function AIChatMessages(props: {
message.content ? 'animate-[fadeIn_500ms_both]' : '',
'shrink-0',
'last:min-h-[calc(100%-5rem)]',
'scroll-mt-36',
'lg:scroll-mt-0',
'flex flex-col gap-6',
message.role === AIMessageRole.User
? 'max-w-[80%] self-end rounded-md bg-tint px-4 py-2'
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
: ''
)}
style={{
@@ -48,6 +54,12 @@ export function AIChatMessages(props: {
))}
</div>
) : null}
{isLastMessage ? (
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
/>
) : null}
</div>
);
})}
@@ -15,7 +15,7 @@ export function AIChatFollowupSuggestions(props: {
}
return (
<div className="flex flex-row flex-wrap justify-end gap-2 pt-8">
<div className="mt-auto flex flex-row flex-wrap justify-end gap-2">
{chat.followUpSuggestions.map((suggestion, index) => (
<Button
key={index}
@@ -23,9 +23,9 @@ export function AIChatFollowupSuggestions(props: {
chatController.postMessage({ message: suggestion });
}}
label={suggestion}
className="max-w-full animate-[present_500ms_both] whitespace-normal text-right"
size="small"
variant="secondary"
className="max-w-[80%] animate-[present_500ms_both] whitespace-normal text-left ring-1 ring-tint-subtle"
size="medium"
variant="blank"
style={{
animationDelay: `${250 + Math.min(index * 50, 150)}ms`,
}}
@@ -1 +1,3 @@
export * from './AIChat';
export * from './AIChatButton';
export * from './AIChatIcon';
@@ -1,13 +1,8 @@
import type { GitBookSiteContext } from '@/lib/context';
import { Suspense } from 'react';
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
import { getSpaceLanguage, t } from '@/intl/server';
import { tcls } from '@/lib/tailwind';
import { CustomizationAIMode } from '@gitbook/api';
import { AIChatButton } from '../AIChat/AIChatButton';
import { SearchButton } from '../Search';
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
import { HeaderLink } from './HeaderLink';
import { HeaderLinkMore } from './HeaderLinkMore';
@@ -22,9 +17,9 @@ import { SpacesDropdown } from './SpacesDropdown';
export function Header(props: {
context: GitBookSiteContext;
withTopHeader?: boolean;
withAIChat?: boolean;
search?: React.ReactNode;
}) {
const { context, withTopHeader, withAIChat } = props;
const { context, withTopHeader, search } = props;
const { siteSpace, siteSpaces, sections, customization } = context;
return (
@@ -123,50 +118,7 @@ export function Header(props: {
: ['order-last']
)}
>
<Suspense fallback={null}>
<SearchButton
style={[
'theme-bold:bg-header-link/2',
'theme-bold:hover:bg-header-link/3',
'theme-bold:text-header-link/8',
'theme-bold:hover:text-header-link',
'theme-bold:ring-header-link/4',
'theme-bold:hover:ring-header-link/5',
'theme-bold:[&_svg]:text-header-link/10',
'theme-bold:[&_.shortcut]:text-header-link/8',
'theme-bold:contrast-more:bg-header-background',
'theme-bold:contrast-more:text-header-link',
'theme-bold:contrast-more:ring-header-link',
'theme-bold:contrast-more:hover:bg-header-background',
'theme-bold:contrast-more:hover:ring-header-link',
'theme-bold:contrast-more:focus:text-header-link',
'theme-bold:contrast-more:focus:bg-header-background',
'theme-bold:contrast-more:focus:ring-header-link',
'theme-bold:shadow-none',
'theme-bold:hover:shadow-none',
'whitespace-nowrap',
]}
>
<span className={tcls('flex-1')}>
{t(
getSpaceLanguage(customization),
// TODO: remove aiSearch and optional chain once the cache has been fully updated (after 11/07/2025)
customization.aiSearch?.enabled ||
customization.ai?.mode !==
CustomizationAIMode.None
? 'search_or_ask'
: 'search'
)}
...
</span>
</SearchButton>
</Suspense>
{withAIChat && <AIChatButton />}
{search}
</div>
{customization.header.links.length > 0 && (
@@ -104,7 +104,7 @@ function HeaderItemButton(
const variant = (() => {
switch (linkStyle) {
case 'button-secondary':
return 'secondary';
return 'header';
case 'button-primary':
return 'primary';
default:
@@ -120,9 +120,7 @@ function HeaderItemButton(
{
'button-primary':
'theme-bold:bg-header-link theme-bold:text-header-background theme-bold:shadow-none theme-bold:hover:bg-header-link theme-bold:hover:text-header-background theme-bold:hover:shadow-none',
'button-secondary': tcls(
'theme-bold:bg-header-link/2 theme-gradient:bg-tint-base theme-muted:bg-tint-base theme-bold:text-header-link theme-bold:shadow-none theme-bold:ring-header-link/4 theme-bold:hover:bg-header-link/3 theme-bold:hover:text-header-link theme-bold:hover:shadow-none theme-bold:hover:ring-header-link/5 theme-bold:contrast-more:bg-header-background theme-bold:contrast-more:text-header-link theme-bold:contrast-more:ring-header-link theme-bold:contrast-more:hover:ring-header-link'
),
'button-secondary': '',
}[linkStyle]
)}
insights={{
@@ -20,7 +20,7 @@ export async function HeaderLogo(props: HeaderLogoProps) {
return (
<Link
href={linker.toAbsoluteURL(linker.toPathInSite(''))}
href={linker.toPathInSite('')}
className={tcls('group/headerlogo', 'min-w-0', 'shrink', 'flex', 'items-center')}
>
{customization.header.logo ? (
@@ -1,11 +1,10 @@
import type { RevisionPageDocument } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { Fragment } from 'react';
import { AIActionsDropdown } from '@/components/AIActions/AIActionsDropdown';
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
import type { GitBookSiteContext } from '@/lib/context';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { type RevisionPageDocument, SiteVisibility } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { PageIcon } from '../PageIcon';
import { StyledLink } from '../primitives';
@@ -21,6 +20,8 @@ export async function PageHeader(props: {
return null;
}
const withAIChat = isAIChatEnabled(context);
return (
<header
className={tcls(
@@ -29,11 +30,27 @@ export async function PageHeader(props: {
'mx-auto',
'mb-6',
'space-y-3',
'page-api-block:ml-0'
'page-api-block:ml-0',
'page-api-block:max-w-full'
)}
>
{page.layout.tableOfContents ? (
<div
className={tcls(
'float-right mb-2 ml-4',
ancestors.length > 0 ? '-mt-2' : 'xs:mt-2'
)}
>
<AIActionsDropdown
markdownPageUrl={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`}
withAIChat={withAIChat}
trademark={context.customization.trademark.enabled}
withLLMActions={context.site.visibility === SiteVisibility.Public}
/>
</div>
) : null}
{ancestors.length > 0 && (
<nav>
<nav aria-label="Breadcrumb">
<ol className={tcls('flex', 'flex-wrap', 'items-center', 'gap-2', 'text-tint')}>
{ancestors.map((breadcrumb, index) => {
const href = linker.toPathForPage({
@@ -41,51 +58,62 @@ export async function PageHeader(props: {
page: breadcrumb,
});
return (
<Fragment key={breadcrumb.id}>
<li key={breadcrumb.id}>
<StyledLink
href={href}
className={tcls(
'no-underline',
'hover:underline',
'text-xs',
'tracking-wide',
'font-semibold',
'uppercase',
'flex',
'items-center',
'gap-1.5',
'contrast-more:underline',
'contrast-more:decoration-current'
)}
>
<PageIcon
page={breadcrumb}
style="flex size-4 items-center justify-center text-base leading-none"
/>
{breadcrumb.title}
</StyledLink>
</li>
<li key={breadcrumb.id} className="flex items-center gap-2">
<StyledLink
href={href}
className={tcls(
'no-underline',
'hover:underline',
'text-xs',
'tracking-wide',
'font-semibold',
'uppercase',
'flex',
'items-center',
'gap-1.5',
'contrast-more:underline',
'contrast-more:decoration-current'
)}
>
<PageIcon
page={breadcrumb}
style="flex size-4 items-center justify-center text-base leading-none"
/>
{breadcrumb.title}
</StyledLink>
{index !== ancestors.length - 1 && (
<Icon
aria-hidden
icon="chevron-right"
className={tcls('size-3', 'text-tint-subtle')}
className="size-3 text-tint-subtle"
/>
)}
</Fragment>
</li>
);
})}
</ol>
</nav>
)}
{page.layout.title ? (
<h1 className={tcls('text-4xl', 'font-bold', 'flex', 'items-center', 'gap-4')}>
<h1
className={tcls(
'text-4xl',
'font-bold',
'flex',
'items-center',
'gap-4',
'grow',
'text-pretty',
'clear-right',
'xs:clear-none'
)}
>
<PageIcon page={page} style={['text-tint-subtle ', 'shrink-0']} />
{page.title}
</h1>
) : null}
{page.description && page.layout.description ? (
<p className={tcls('text-lg', 'text-tint')}>{page.description}</p>
<p className={tcls('text-lg', 'text-tint', 'clear-both')}>{page.description}</p>
) : null}
</header>
);
@@ -1,5 +1,4 @@
import {
CustomizationHeaderPreset,
CustomizationIconsStyle,
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
@@ -86,11 +85,6 @@ export async function CustomizationRootLayout(props: {
suppressHydrationWarning
lang={customization.internationalization.locale}
className={tcls(
/* This offset is used when scrolling to elements on the page, like #anchors and (on mobile) the latest AI chat message.
* It is set to the maximum height of the header (64px) + sections (44px) + offset (40px) */
customization.header.preset === CustomizationHeaderPreset.None
? null
: 'scroll-pt-[148px]',
customization.styling.corners && `${customization.styling.corners}-corners`,
'theme' in customization.styling && `theme-${customization.styling.theme}`,
tintColor ? ' tint' : 'no-tint',
@@ -23,6 +23,9 @@ export function HighlightQuery(props: {
'py-0.5',
'rounded',
'straight-corners:rounded-sm',
'transition-colors',
'group-hover:bg-primary-active',
'group-hover:text-contrast-primary-active',
'group-[.is-active]:bg-primary-active',
'group-[.is-active]:text-contrast-primary-active',
],
@@ -88,20 +88,22 @@ export function SearchAskAnswer(props: { query: string }) {
}, [setAskState]);
const loading = (
<div className={tcls('w-full', 'flex', 'items-center', 'justify-center')}>
<Loading className={tcls('w-6', 'py-8', 'text-primary-subtle')} />
<div className="flex grow items-center justify-center">
<Loading className={tcls('size-6', 'text-tint/6')} />
</div>
);
return (
<div className={tcls('max-h-[60vh]', 'overflow-y-auto')}>
<div className="flex min-h-full p-4">
{askState?.type === 'answer' ? (
<React.Suspense fallback={loading}>
<TransitionAnswerBody answer={askState.answer} placeholder={loading} />
</React.Suspense>
) : null}
{askState?.type === 'error' ? (
<div className={tcls('p-4')}>{t(language, 'search_ask_error')}</div>
<div className={tcls('grow p-4 text-center')}>
{t(language, 'search_ask_error')}
</div>
) : null}
{askState?.type === 'loading' ? loading : null}
</div>
@@ -138,10 +140,7 @@ function AnswerBody(props: { answer: AskAnswerResult }) {
return (
<>
<div
data-testid="search-ask-answer"
className={tcls('my-4', 'sm:mt-6', 'px-4', 'sm:px-12', 'text-tint-strong')}
>
<div data-testid="search-ask-answer" className="text-tint-strong">
{answer.body ?? t(language, 'search_ask_no_answer')}
{answer.followupQuestions.length > 0 ? (
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />
@@ -212,8 +211,7 @@ function AnswerSources(props: {
'gap-2',
'mt-4',
'sm:mt-6',
'py-4',
'px-4',
'pt-4',
'border-t',
'border-subtle'
)}
@@ -1,144 +0,0 @@
'use client';
import { Icon } from '@gitbook/icons';
import { useEffect, useState } from 'react';
import { tString, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { useTrackEvent } from '../Insights';
import { useSearch } from './useSearch';
/**
* Button to open the search modal.
*/
export function SearchButton(props: { children?: React.ReactNode; style?: ClassValue }) {
const { style, children } = props;
const language = useLanguage();
const [, setSearchState] = useSearch();
const trackEvent = useTrackEvent();
const onClick = () => {
setSearchState({
ask: false,
global: false,
query: '',
});
trackEvent({
type: 'search_open',
});
};
return (
<button
type="button"
onClick={onClick}
aria-label={tString(language, 'search')}
className={tcls(
'flex',
'flex-1',
'flex-row',
'justify-center',
'items-center',
'w-full',
'py-2.5',
'md:py-2',
'px-3',
'circular-corners:px-4',
'gap-2',
'bg-tint-base',
'ring-1',
'ring-tint-12/2',
'depth-flat:ring-tint-subtle',
'shadow-sm',
'shadow-tint-12/3',
'dark:shadow-none',
'depth-flat:shadow-none',
'text-tint',
'rounded-lg',
'straight-corners:rounded-sm',
'circular-corners:rounded-full',
'contrast-more:ring-tint-12',
'contrast-more:text-tint-strong',
'transition-all',
'hover:bg-tint-subtle',
'hover:shadow-md',
'hover:scale-102',
'depth-flat:hover:scale-100',
'hover:ring-tint-hover',
'hover:text-tint-strong',
'focus:shadow-md',
'focus:scale-102',
'depth-flat:focus:scale-100',
'focus:ring-tint-hover',
'focus:text-tint-strong',
'contrast-more:hover:ring-2',
'contrast-more:hover:ring-tint-12',
'contrast-more:focus:ring-2',
'contrast-more:focus:ring-tint-12',
'active:shadow-sm',
'active:scale-100',
'md:justify-start',
'md:w-full',
'text-sm',
style
)}
>
<Icon
icon="magnifying-glass"
className={tcls('text-tint-subtle', 'shrink-0', 'size-4')}
/>
<div className={tcls('w-full', 'hidden', 'md:block', 'text-left')}>{children}</div>
<Shortcut />
</button>
);
}
function Shortcut() {
const [operatingSystem, setOperatingSystem] = useState<string | null>(null);
useEffect(() => {
function getOperatingSystem() {
const platform = navigator.platform.toLowerCase();
if (platform.includes('mac')) return 'mac';
if (platform.includes('win')) return 'win';
return 'win';
}
setOperatingSystem(getOperatingSystem());
}, []);
return (
<div
aria-busy={operatingSystem === null ? 'true' : undefined}
className={tcls(
`shortcut -mr-1 hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt",_"case"] contrast-more:text-tint-strong md:flex`,
operatingSystem
? 'motion-safe:animate-fadeIn motion-reduce:opacity-100'
: 'opacity-0'
)}
>
<kbd
className={`flex h-5 min-w-5 items-center justify-center rounded border border-tint-subtle theme-bold:border-header-link/5 bg-tint-base theme-bold:bg-header-background px-1 ${operatingSystem === 'mac' ? 'text-sm' : ''}`}
>
{operatingSystem === 'mac' ? '⌘' : 'Ctrl'}
</kbd>
<kbd className="flex size-5 items-center justify-center rounded border border-tint-subtle theme-bold:border-header-link/5 bg-tint-base theme-bold:bg-header-background">
K
</kbd>
</div>
);
}
@@ -0,0 +1,184 @@
'use client';
import { CustomizationAIMode } from '@gitbook/api';
import { useRouter } from 'next/navigation';
import React, { useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTrackEvent } from '../Insights';
import { useIsMobile } from '../hooks/useIsMobile';
import { Popover } from '../primitives';
import { SearchAskAnswer } from './SearchAskAnswer';
import { useSearchAskState } from './SearchAskContext';
import { SearchAskProvider } from './SearchAskContext';
import { SearchInput } from './SearchInput';
import { SearchResults, type SearchResultsRef } from './SearchResults';
import { SearchScopeToggle } from './SearchScopeToggle';
import { useSearch } from './useSearch';
interface SearchContainerProps {
spaceTitle: string;
isMultiVariants: boolean;
aiMode: CustomizationAIMode;
className?: string;
}
/**
* Client component to render the search input and results.
*/
export function SearchContainer(props: SearchContainerProps) {
const { spaceTitle, isMultiVariants, aiMode, className } = props;
const withAI =
aiMode === CustomizationAIMode.Search || aiMode === CustomizationAIMode.Assistant;
const [state, setSearchState] = useSearch();
const searchAsk = useSearchAskState();
const router = useRouter();
const trackEvent = useTrackEvent();
const resultsRef = useRef<SearchResultsRef>(null);
const searchInputRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile();
// Derive open state from search state
const open = state?.open ?? false;
const onClose = async (to?: string) => {
if (state?.query === '') {
await setSearchState(null);
} else if (state) {
await setSearchState({ ...state, open: false });
}
if (to) {
router.push(to);
}
};
useHotkeys(
'mod+k',
(e) => {
e.preventDefault();
onOpen();
},
[]
);
const onOpen = () => {
if (open) {
return;
}
setSearchState((prev) => ({
ask: prev?.ask ?? false,
global: prev?.global ?? false,
query: prev?.query ?? '',
open: true,
}));
trackEvent({
type: 'search_open',
});
};
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowUp') {
event.preventDefault();
resultsRef.current?.moveUp();
} else if (event.key === 'ArrowDown') {
event.preventDefault();
resultsRef.current?.moveDown();
} else if (event.key === 'Enter') {
event.preventDefault();
resultsRef.current?.select();
}
};
const onChange = (value: string) => {
setSearchState((prev) => ({
ask: false, // When typing, we go back to the default search mode
query: value,
global: prev?.global ?? false,
open: true,
}));
};
// We trim the query to avoid invalidating the search when the user is typing between words.
const normalizedQuery = state?.query.trim() ?? '';
return (
<SearchAskProvider value={searchAsk}>
<Popover
content={
// Only show content if there's a query or Ask is enabled
(state?.query || withAI) && open ? (
<React.Suspense fallback={null}>
{isMultiVariants && !state?.ask ? (
<SearchScopeToggle spaceTitle={spaceTitle} />
) : null}
{state !== null && !state.ask ? (
<SearchResults
ref={resultsRef}
query={normalizedQuery}
global={state?.global ?? false}
aiMode={aiMode}
/>
) : null}
{state?.ask ? <SearchAskAnswer query={normalizedQuery} /> : null}
</React.Suspense>
) : null
}
rootProps={{
open: open,
modal: isMobile,
}}
contentProps={{
onOpenAutoFocus: (event) => event.preventDefault(),
align: 'start',
className:
'bg-tint-base has-[.empty]:hidden scroll-py-2 w-[32rem] p-2 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
onInteractOutside: (event) => {
// Don't close if clicking on the search input itself
if (searchInputRef.current?.contains(event.target as Node)) {
return;
}
onClose();
},
sideOffset: 8,
collisionPadding: {
top: 16,
right: 16,
bottom: 32,
left: 16,
},
hideWhenDetached: true,
}}
triggerProps={{
asChild: true,
}}
>
<SearchInput
ref={searchInputRef}
value={state?.query ?? ''}
onFocus={onOpen}
onChange={onChange}
onKeyDown={onKeyDown}
withAI={withAI}
isOpen={open}
className={className}
/>
</Popover>
</SearchAskProvider>
);
}
@@ -0,0 +1,143 @@
'use client';
import React from 'react';
import { useEffect, useRef, useState } from 'react';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { Button, variantClasses } from '../primitives';
import { useClassnames } from '../primitives/StyleProvider';
interface SearchInputProps {
onChange: (value: string) => void;
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onFocus: () => void;
value: string;
withAI?: boolean;
isOpen: boolean;
className?: string;
}
// Size classes for medium size button
const sizeClasses = ['text-sm', 'px-3.5', 'py-1.5', 'md:circular-corners:px-4'];
/**
* Input to trigger search.
*/
export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
function SearchInput(props, ref) {
const { onChange, onKeyDown, onFocus, value, withAI = false, isOpen, className } = props;
const inputRef = useRef<HTMLInputElement>(null);
const language = useLanguage();
const buttonStyles = useClassnames(['ButtonStyles']);
useEffect(() => {
if (isOpen) {
if (document.activeElement !== inputRef.current) {
// Refocus the input and move the caret to the end do this only once to avoid scroll jumps on every keystroke
inputRef.current?.focus({ preventScroll: true });
// Place cursor at the end of the input
inputRef.current?.setSelectionRange(value.length, value.length);
}
} else {
inputRef.current?.blur();
}
}, [isOpen, value]);
return (
<div className="relative flex size-9 grow">
{/* biome-ignore lint/a11y/useKeyWithClickEvents: this div needs an onClick to show the input on mobile, where it's normally hidden.
Normally you'd also need to add a keyboard trigger to do the same without a pointer, but in this case the input already be focused on its own. */}
<div
ref={ref}
onClick={onFocus}
className={tcls(
// Apply button styles
buttonStyles,
variantClasses.header,
sizeClasses,
// Additional custom styles
'has-[input:focus]:-translate-y-px h-9 grow cursor-pointer px-2.5 has-[input:focus]:bg-tint-base depth-subtle:has-[input:focus]:shadow-lg depth-subtle:has-[input:focus]:shadow-primary-subtle has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-primary-hover md:cursor-text',
'theme-bold:has-[input:focus-visible]:border-header-link/6 theme-bold:has-[input:focus-visible]:bg-header-link/3',
'theme-bold:before:absolute theme-bold:before:inset-0 theme-bold:before:bg-header-background/7 theme-bold:before:backdrop-blur-xl ', // Special overlay to make the transparent colors of theme-bold visible.
'relative z-30 shrink grow justify-start max-md:absolute max-md:right-0',
isOpen ? 'max-md:w-56' : 'max-md:w-[38px]',
className
)}
>
{value && isOpen ? (
<Button
variant="blank"
label="Clear"
size="medium"
iconOnly
icon="circle-xmark"
className="-ml-1.5 -mr-1 animate-scaleIn px-1.5 theme-bold:text-header-link theme-bold:hover:bg-header-link/3"
onClick={() => {
onChange('');
inputRef.current?.focus();
}}
/>
) : (
<Icon icon="magnifying-glass" className="size-4 shrink-0 animate-scaleIn" />
)}
<input
type="text"
onFocus={onFocus}
onKeyDown={onKeyDown}
onChange={(event) => onChange(event.target.value)}
value={value}
placeholder={`${tString(language, withAI ? 'search_or_ask' : 'search')}...`}
maxLength={512}
className={tcls(
'peer z-10 min-w-0 grow bg-transparent py-0.5 text-tint-strong theme-bold:text-header-link outline-none transition-[width] duration-300 contain-paint placeholder:text-tint theme-bold:placeholder:text-current theme-bold:placeholder:opacity-7',
isOpen ? '' : 'max-md:opacity-0'
)}
ref={inputRef}
/>
<Shortcut />
</div>
</div>
);
}
);
function getOperatingSystem() {
const platform = navigator.platform.toLowerCase();
if (platform.includes('mac')) return 'mac';
if (platform.includes('win')) return 'win';
return 'win';
}
function Shortcut() {
const [operatingSystem, setOperatingSystem] = useState<string | null>(null);
useEffect(() => {
setOperatingSystem(getOperatingSystem());
}, []);
return (
<div
aria-busy={operatingSystem === null ? 'true' : undefined}
className={tcls(
`shortcut -mr-1 hidden justify-end gap-0.5 whitespace-nowrap text-xs [font-feature-settings:"calt",_"case"] contrast-more:text-tint-strong md:flex`,
operatingSystem
? 'motion-safe:animate-fadeIn motion-reduce:opacity-11'
: 'opacity-0'
)}
>
<kbd
className={`flex h-5 min-w-5 items-center justify-center rounded border border-tint-subtle theme-bold:border-header-link/5 bg-tint-base theme-bold:bg-header-background px-1 ${operatingSystem === 'mac' ? 'text-sm' : ''}`}
>
{operatingSystem === 'mac' ? '⌘' : 'Ctrl'}
</kbd>
<kbd className="flex size-5 items-center justify-center rounded border border-tint-subtle theme-bold:border-header-link/5 bg-tint-base theme-bold:bg-header-background px-1">
K
</kbd>
</div>
);
}
@@ -1,309 +0,0 @@
'use client';
import { Icon } from '@gitbook/icons';
import { AnimatePresence, motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { LoadingPane } from '../primitives/LoadingPane';
import { SearchAskAnswer } from './SearchAskAnswer';
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
import { SearchResults, type SearchResultsRef } from './SearchResults';
import { SearchScopeToggle } from './SearchScopeToggle';
import { type SearchState, type UpdateSearchState, useSearch } from './useSearch';
interface SearchModalProps {
spaceTitle: string;
isMultiVariants: boolean;
withAsk: boolean;
withAIChat: boolean;
}
/**
* Client component to render the search modal when the url contains a search query.
*/
export function SearchModal(props: SearchModalProps) {
const [state, setSearchState] = useSearch();
const searchAsk = useSearchAskState();
const [askState] = searchAsk;
const router = useRouter();
useHotkeys(
'mod+k',
(e) => {
e.preventDefault();
setSearchState({ ask: false, query: '', global: false });
},
[]
);
// Add a global class on the body when the search modal is open
const isSearchOpened = state !== null;
React.useEffect(() => {
if (isSearchOpened) {
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = 'auto';
};
}, [isSearchOpened]);
const onClose = async (to?: string) => {
await setSearchState(null);
if (to) {
router.push(to);
}
};
return (
<SearchAskProvider value={searchAsk}>
<AnimatePresence>
{state !== null ? (
<motion.div
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
transition={{
duration: 0.2,
delay: 0.1,
}}
role="dialog"
className={tcls(
'fixed',
'inset-0',
'bg-tint-12/4',
'dark:bg-tint-1/6',
'backdrop-blur-2xl',
'z-50',
'px-4',
'pt-4',
'md:pt-[min(8vh,6rem)]'
)}
onClick={() => {
onClose();
}}
>
<div className="scroll-nojump">
<AnimatePresence>
{askState?.type === 'loading' ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 1 }}
className={tcls(
'w-screen',
'h-screen',
'fixed',
'inset-0',
'z-10',
'pointer-events-none'
)}
>
<LoadingPane
gridStyle={['h-screen', 'aspect-auto', 'top-[-30%]']}
pulse
tile={96}
style={['grid']}
/>
</motion.div>
) : null}
</AnimatePresence>
<SearchModalBody
{...props}
state={state}
setSearchState={setSearchState}
onClose={onClose}
/>
</div>
</motion.div>
) : null}
</AnimatePresence>
</SearchAskProvider>
);
}
function SearchModalBody(
props: SearchModalProps & {
state: SearchState;
setSearchState: UpdateSearchState;
onClose: (to?: string) => void;
}
) {
const { spaceTitle, withAsk, withAIChat, isMultiVariants, state, setSearchState, onClose } =
props;
const language = useLanguage();
const resultsRef = React.useRef<SearchResultsRef>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
inputRef.current?.focus();
}, []);
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowUp') {
event.preventDefault();
resultsRef.current?.moveUp();
} else if (event.key === 'ArrowDown') {
event.preventDefault();
resultsRef.current?.moveDown();
} else if (event.key === 'Enter') {
event.preventDefault();
resultsRef.current?.select();
}
};
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchState({
ask: false, // When typing, we go back to the default search mode
query: event.target.value,
global: state.global,
});
};
const onSwitchToAsk = () => {
setSearchState((state) => (state ? { ...state, ask: true } : null));
};
// We trim the query to avoid invalidating the search when the user is typing between words.
const normalizedQuery = state.query.trim();
return (
<motion.div
transition={{
duration: 0.2,
delay: 0.1,
ease: 'easeOut',
}}
initial={{
scale: 0.95,
opacity: 0,
}}
animate={{
scale: 1,
opacity: 1,
}}
exit={{
scale: 0.95,
opacity: 0,
}}
role="dialog"
aria-label={tString(language, 'search')}
className={tcls(
'z-40',
'relative',
'flex',
'flex-col',
'bg-tint-base',
'max-w-prose',
'mx-auto',
'max-h-[70dvh]',
'w-full',
'rounded-lg',
'straight-corners:rounded-sm',
'circular-corners:rounded-2xl',
'ring-1',
'ring-tint',
'shadow-2xl',
'depth-flat:shadow-none',
'overflow-hidden',
'dark:ring-inset',
'dark:ring-tint'
)}
onClick={(event) => {
event.stopPropagation();
}}
>
<div
className={tcls(
'flex',
'flex-row',
'items-start',
state.query !== null ? 'border-b' : null,
'border-tint-subtle'
)}
>
<div className={tcls('p-2', 'pl-4', 'pt-4')}>
<Icon icon="magnifying-glass" className={tcls('size-4', 'text-tint-subtle')} />
</div>
<div
className={tcls(
'w-full',
'flex',
'flex-row',
'flex-wrap',
'gap-y-0',
'gap-x-4',
'items-end'
)}
>
<input
ref={inputRef}
value={state.query}
onKeyDown={onKeyDown}
onChange={onChange}
className={tcls(
'text-tint-strong',
'placeholder:text-tint',
'flex',
'resize-none',
'flex-1',
'h-12',
'p-2',
'focus:outline-none',
'bg-transparent',
'whitespace-pre-line'
)}
placeholder={tString(
language,
withAsk ? 'search_ask_input_placeholder' : 'search_input_placeholder'
)}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
/>
{isMultiVariants ? <SearchScopeToggle spaceTitle={spaceTitle} /> : null}
</div>
</div>
{!state.ask || !withAsk ? (
<SearchResults
ref={resultsRef}
global={isMultiVariants && state.global}
query={normalizedQuery}
withAsk={withAsk}
withAIChat={withAIChat}
onSwitchToAsk={onSwitchToAsk}
/>
) : null}
{normalizedQuery && state.ask && withAsk ? (
<SearchAskAnswer query={normalizedQuery} />
) : null}
</motion.div>
);
}
@@ -1,9 +1,9 @@
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import React from 'react';
import { Link } from '../primitives';
import { HighlightQuery } from './HighlightQuery';
import { SearchResultItem } from './SearchResultItem';
import type { ComputedPageResult } from './server-actions';
export const SearchPageResultItem = React.forwardRef(function SearchPageResultItem(
@@ -15,6 +15,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
ref: React.Ref<HTMLAnchorElement>
) {
const { query, item, active } = props;
const language = useLanguage();
const breadcrumbs =
item.breadcrumbs?.map((crumb) => (
@@ -25,26 +26,12 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
)) ?? [];
return (
<Link
<SearchResultItem
ref={ref}
href={item.href}
className={tcls(
'flex',
'gap-4',
'flex-row',
'items-center',
'p-4',
'border-t',
'border-tint-subtle',
'first:border-none',
'text-base',
'font-medium',
'hover:bg-tint-hover',
'group',
active
? ['is-active', 'bg-primary', 'text-contrast-primary', 'hover:bg-primary-hover']
: null
)}
active={active}
action={tString(language, 'view')}
leadingIcon={<Icon icon="memo" className="size-4" />}
insights={{
type: 'search_open_result',
query,
@@ -54,69 +41,50 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
},
}}
>
<div className="size-4">
<Icon
icon="file-lines"
className={tcls('size-4', active ? 'text-primary' : 'text-tint-subtle')}
/>
</div>
<div className={tcls('flex', 'flex-col', 'w-full')}>
{breadcrumbs.length > 0 ? (
<div
className={tcls(
'text-xs',
'opacity-6',
'contrast-more:opacity-11',
'font-normal',
'uppercase',
'tracking-wider',
'mb-1',
'flex',
'flex-wrap',
'gap-x-2',
'gap-y-1',
'items-center'
)}
>
{(breadcrumbs.length > 3
? [
...breadcrumbs.slice(0, 2),
<Icon key="ellipsis" icon="ellipsis-h" className="size-3" />,
...breadcrumbs.slice(-1),
]
: breadcrumbs
).map((crumb, index) => (
<>
{index !== 0 ? (
<Icon
key={`${crumb.key}-icon`}
icon="chevron-right"
className="size-3"
/>
) : null}
<span key={crumb.key} className="line-clamp-1">
{crumb}
</span>
</>
))}
</div>
) : null}
{breadcrumbs.length > 0 ? (
<div
className={tcls(
'text-xs',
'text-tint/7',
'contrast-more:text-tint',
'group-[.is-active]:text-tint',
'transition-colors',
'font-normal',
'uppercase',
'leading-none',
'tracking-wider',
'mb-1',
'flex',
'flex-wrap',
'gap-x-2',
'gap-y-1',
'items-center'
)}
>
{(breadcrumbs.length > 3
? [
...breadcrumbs.slice(0, 2),
<Icon key="ellipsis" icon="ellipsis-h" className="size-3" />,
...breadcrumbs.slice(-1),
]
: breadcrumbs
).map((crumb, index) => (
<React.Fragment key={index}>
{index !== 0 ? (
<Icon
key={`${crumb.key}-icon`}
icon="chevron-right"
className="size-2"
/>
) : null}
<span className="line-clamp-1">{crumb}</span>
</React.Fragment>
))}
</div>
) : null}
<p className="line-clamp-2 font-semibold text-base text-tint-strong leading-snug">
<HighlightQuery query={query} text={item.title} />
</div>
<div
className={tcls(
'p-2',
'rounded',
'straight-corners:rounded-none',
'circular-corners:rounded-full',
active ? ['bg-primary-solid', 'text-contrast-primary-solid'] : ['opacity-6']
)}
>
<Icon
icon={active ? 'arrow-turn-down-left' : 'chevron-right'}
className={tcls('size-4')}
/>
</div>
</Link>
</p>
</SearchResultItem>
);
});
@@ -1,101 +1,73 @@
import { Icon } from '@gitbook/icons';
import React from 'react';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { t, tString, useLanguage } from '@/intl/client';
import { Icon } from '@gitbook/icons';
import { useAIChatController } from '../AI';
import { Link } from '../primitives';
import { AIChatIcon } from '../AIChat/';
import { SearchResultItem } from './SearchResultItem';
import { useSearch, useSearchLink } from './useSearch';
export const SearchQuestionResultItem = React.forwardRef(function SearchQuestionResultItem(
props: {
question: string;
active: boolean;
onClick: () => void;
recommended?: boolean;
withAIChat: boolean;
},
ref: React.Ref<HTMLAnchorElement>
) {
const { question, recommended = false, active, onClick, withAIChat } = props;
const { question, recommended = false, active, withAIChat } = props;
const language = useLanguage();
const getLinkProp = useSearchLink();
const chatController = useAIChatController();
const [, setSearchState] = useSearch();
return (
<Link
<SearchResultItem
size={recommended ? 'small' : 'medium'}
action={tString(language, 'ask', '')}
ref={ref}
data-testid="search-result-item"
scroll={false}
{...(withAIChat
? { href: '#' }
: getLinkProp({
ask: true,
open: true,
query: question,
}))}
onClick={() => {
if (withAIChat) {
// If AI Chat is enabled, hijack to open the chat and post the question
chatController.open();
chatController.postMessage({ message: question });
setSearchState(null); // Close the search modal
} else {
onClick();
}
}}
data-testid="search-result-item"
className={tcls(
'flex',
'px-4',
recommended ? ['py-2', 'text-tint'] : 'py-4',
'hover:bg-tint-hover',
'first:mt-0',
'last:pb-3',
active && [
'is-active',
'bg-primary',
'text-contrast-primary',
'hover:bg-primary-hover',
]
)}
{...(withAIChat
? { href: '#' }
: getLinkProp({
ask: true,
query: question,
}))}
>
<Icon
icon={recommended ? 'search' : 'sparkles'}
className={tcls(
'size-4',
'shrink-0',
'mt-1.5',
'mr-4',
active ? ['text-primary'] : ['text-tint-subtle']
)}
/>
<div className="w-full">
{recommended ? (
question
active={active}
leadingIcon={
recommended ? (
<Icon icon="search" className="size-4" />
) : withAIChat ? (
<AIChatIcon className="size-4" />
) : (
<>
<div className="font-medium">{t(language, 'search_ask', [question])}</div>
<div className={tcls('text-sm', 'text-tint')}>
{t(language, 'search_ask_description')}
</div>
</>
)}
</div>
<div
className={tcls(
'p-2',
'rounded',
'self-center',
'straight-corners:rounded-none',
'circular-corners:rounded-full',
active ? ['bg-primary-solid', 'text-contrast-primary-solid'] : ['opacity-6']
)}
>
<Icon
icon={active ? 'arrow-turn-down-left' : 'chevron-right'}
className={tcls('size-4')}
/>
</div>
</Link>
<Icon icon="sparkles" className="size-4" />
)
}
className={recommended ? 'pr-1.5' : ''}
>
{recommended ? (
question
) : (
<>
<div className="font-semibold text-base text-tint-strong leading-tight">
{t(language, 'search_ask', [question])}
</div>
<div className="text-sm text-tint-subtle">
{t(language, 'search_ask_description')}
</div>
</>
)}
</SearchResultItem>
);
});
@@ -0,0 +1,73 @@
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import * as React from 'react';
import { Button } from '../primitives';
import { Link, type LinkInsightsProps } from '../primitives/Link';
export const SearchResultItem = React.forwardRef(function SearchResultItem(
props: {
children: React.ReactNode;
href: string;
action: string;
active: boolean;
className?: string;
size?: 'small' | 'medium';
leadingIcon?: React.ReactNode;
} & LinkInsightsProps &
Omit<React.ComponentProps<typeof Link>, keyof LinkInsightsProps>,
ref: React.Ref<HTMLAnchorElement>
) {
const {
children,
href,
active,
className,
leadingIcon,
size = 'medium',
action,
...rest
} = props;
return (
<Link
ref={ref}
href={href}
className={tcls(
'flex',
'items-center',
'gap-3',
'group',
'px-4',
size === 'small' ? 'py-1.5' : 'py-3',
'text-tint',
'hover:bg-tint',
'hover:text-tint-strong',
'group',
'transition-colors',
'rounded-corners:rounded-md',
'circular-corners:rounded-2xl',
active
? ['is-active', 'bg-primary', 'text-primary-strong', 'hover:bg-primary']
: null,
className
)}
{...rest}
>
<div className="size-4 shrink-0 text-tint-subtle">{leadingIcon}</div>
<div className="grow">{children}</div>
{active ? (
<Button
label={action}
iconOnly
variant="primary"
className="p-2"
icon="arrow-turn-down-left"
/>
) : (
<div className="flex size-8 shrink-0 items-center justify-center text-tint-subtle">
<Icon icon="chevron-right" className="size-3" />
</div>
)}
</Link>
);
});
@@ -7,6 +7,7 @@ import React from 'react';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { CustomizationAIMode } from '@gitbook/api';
import { useTrackEvent } from '../Insights';
import { Loading } from '../primitives';
import { SearchPageResultItem } from './SearchPageResultItem';
@@ -40,20 +41,18 @@ let cachedRecommendedQuestions: null | ResultType[] = null;
* Fetch the results of the keyboard navigable elements to display for a query:
* - Recommended questions if no query is provided.
* - Search results if a query is provided.
* - If withAsk is true, add a question result.
* - If withAI is true, add a question result.
*/
export const SearchResults = React.forwardRef(function SearchResults(
props: {
children?: React.ReactNode;
query: string;
global: boolean;
withAsk: boolean;
withAIChat: boolean;
onSwitchToAsk: () => void;
aiMode: CustomizationAIMode;
},
ref: React.Ref<SearchResultsRef>
) {
const { children, query, withAsk, withAIChat, global, onSwitchToAsk } = props;
const { children, query, aiMode, global } = props;
const language = useLanguage();
const trackEvent = useTrackEvent();
@@ -64,9 +63,12 @@ export const SearchResults = React.forwardRef(function SearchResults(
const [cursor, setCursor] = React.useState<number | null>(null);
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
const withAI =
aiMode === CustomizationAIMode.Search || aiMode === CustomizationAIMode.Assistant;
React.useEffect(() => {
if (!query) {
if (!withAsk) {
if (!withAI) {
setResultsState({ results: [], fetching: false });
return;
}
@@ -76,11 +78,10 @@ export const SearchResults = React.forwardRef(function SearchResults(
return;
}
let cancelled = false;
// Silently fetch the recommended questions, instead of showing a spinner
setResultsState({ results: [], fetching: false });
let cancelled = false;
// We currently have a bug where the same question can be returned multiple times.
// This is a workaround to avoid that.
const questions = new Set<string>();
@@ -149,14 +150,14 @@ export const SearchResults = React.forwardRef(function SearchResults(
cancelled = true;
clearTimeout(timeout);
};
}, [query, global, withAsk, trackEvent]);
}, [query, global, withAI, trackEvent]);
const results: ResultType[] = React.useMemo(() => {
if (!withAsk) {
if (!withAI) {
return resultsState.results;
}
return withQuestionResult(resultsState.results, query);
}, [resultsState.results, query, withAsk]);
}, [resultsState.results, query, withAI]);
React.useEffect(() => {
if (!query) {
@@ -217,28 +218,39 @@ export const SearchResults = React.forwardRef(function SearchResults(
if (resultsState.fetching) {
return (
<div className={tcls('flex', 'items-center', 'justify-center', 'py-8')}>
<Loading className={tcls('w-6', 'text-primary')} />
<div className={tcls('flex', 'items-center', 'justify-center', 'py-8', 'h-full')}>
<Loading className={tcls('w-6', 'text-tint/6')} />
</div>
);
}
const noResults = (
<div className={tcls('text', 'text-tint', 'p-8', 'text-center')}>
<div
className={tcls(
'flex',
'items-center',
'justify-center',
'text-center',
'py-8',
'h-full'
)}
>
{t(language, 'search_no_results_for', query)}
</div>
);
return (
<div className={tcls('overflow-auto')}>
<div className={tcls('min-h-full')}>
{children}
{results.length === 0 ? (
query ? (
noResults
) : null
) : (
<div className="empty" />
)
) : (
<>
<div data-testid="search-results">
<div data-testid="search-results" className="flex flex-col gap-y-1">
{results.map((item, index) => {
switch (item.type) {
case 'page': {
@@ -260,11 +272,10 @@ export const SearchResults = React.forwardRef(function SearchResults(
ref={(ref) => {
refs.current[index] = ref;
}}
withAIChat={withAIChat}
withAIChat={aiMode === CustomizationAIMode.Assistant}
key={item.id}
question={query}
active={index === cursor}
onClick={onSwitchToAsk}
/>
);
}
@@ -275,10 +286,9 @@ export const SearchResults = React.forwardRef(function SearchResults(
refs.current[index] = ref;
}}
key={item.id}
withAIChat={withAIChat}
withAIChat={aiMode === CustomizationAIMode.Assistant}
question={item.question}
active={index === cursor}
onClick={onSwitchToAsk}
recommended
/>
);
@@ -1,6 +1,5 @@
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { tString, useLanguage } from '@/intl/client';
import { Button } from '../primitives';
import { useSearch } from './useSearch';
/**
@@ -20,54 +19,34 @@ export function SearchScopeToggle(props: { spaceTitle: string }) {
<div
role="toolbar"
aria-orientation="horizontal"
className={tcls('flex', 'flex-row', 'mr-4')}
className="mb-2 flex flex-row flex-wrap gap-1 circular-corners:rounded-3xl rounded-corners:rounded-lg bg-tint-subtle p-1"
>
<ToggleButton
<Button
variant="blank"
size="medium"
className="shrink grow justify-center whitespace-normal"
active={!state.global}
label={tString(language, 'search_scope_space', spaceTitle)}
onClick={() => {
setSearchState({
...state,
global: false,
});
}}
>
{t(language, 'search_scope_space', spaceTitle)}
</ToggleButton>
<ToggleButton
/>
<Button
variant="blank"
size="medium"
className="shrink grow justify-center whitespace-normal"
active={state.global}
label={tString(language, 'search_scope_all')}
onClick={() => {
setSearchState({
...state,
global: true,
});
}}
>
{t(language, 'search_scope_all')}
</ToggleButton>
/>
</div>
);
}
function ToggleButton(props: { onClick: () => void; children: React.ReactNode; active: boolean }) {
const { onClick, children, active } = props;
return (
<button
role="tab"
type="button"
aria-selected={active}
onClick={onClick}
className={tcls(
'text-sm',
'transition-colors',
'px-3',
'py-2',
'border-b-2',
'whitespace-pre',
'-mb-[1px]',
active ? ['text-primary', 'border-primary'] : ['border-transparent']
)}
>
{children}
</button>
);
}
@@ -1,10 +1,8 @@
import { Icon } from '@gitbook/icons';
import React from 'react';
import { tcls } from '@/lib/tailwind';
import { Link } from '../primitives';
import { tString, useLanguage } from '@/intl/client';
import { HighlightQuery } from './HighlightQuery';
import { SearchResultItem } from './SearchResultItem';
import type { ComputedSectionResult } from './server-actions';
export const SearchSectionResultItem = React.forwardRef(function SearchSectionResultItem(
@@ -16,30 +14,15 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
ref: React.Ref<HTMLAnchorElement>
) {
const { query, item, active } = props;
const language = useLanguage();
return (
<Link
<SearchResultItem
ref={ref}
href={item.href}
className={tcls(
'[&:has(+:not(&))]:mb-6',
'flex',
'items-center',
'pl-6',
'sm:pl-12',
'pr-4',
'text-tint',
'hover:bg-tint-hover',
'font-normal',
'py-2',
'group',
active && [
'is-active',
'bg-primary',
'text-contrast-primary',
'hover:bg-primary-hover',
]
)}
size="small"
active={active}
action={tString(language, 'view')}
insights={{
type: 'search_open_result',
query,
@@ -49,41 +32,15 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
},
}}
>
<div
className={tcls(
'border-l-2',
'px-4',
'py-1',
'flex',
'flex-1',
'overflow-hidden',
'flex-col',
'border-tint-subtle'
)}
>
<div className="grow border-tint-subtle border-l-2 pl-4">
{item.title ? (
<p className={tcls('text-base', 'mb-2')}>
<p className="font-semibold">
<HighlightQuery query={query} text={item.title} />
</p>
) : null}
{item.body ? highlightQueryInBody(item.body, query) : null}
</div>
<div
className={tcls(
'p-2',
'rounded',
'straight-corners:rounded-none',
'circular-corners:rounded-full',
'bg-primary-solid',
'text-contrast-primary-solid',
'hidden',
'sm:block',
active ? ['opacity-11', 'block'] : ['opacity-0']
)}
>
<Icon icon="arrow-turn-down-left" className={tcls('size-4')} />
</div>
</Link>
</SearchResultItem>
);
});
@@ -92,7 +49,7 @@ function highlightQueryInBody(body: string, query: string) {
// Ensure the query to be highlighted is visible in the body.
return (
<p className={tcls('text-sm', 'line-clamp-3', 'relative')}>
<p className="relative line-clamp-3 text-sm [overflow-wrap:anywhere]">
<HighlightQuery query={query} text={idx < 20 ? body : `...${body.slice(idx - 10)}`} />
</p>
);
@@ -1,2 +1,2 @@
export * from './SearchButton';
export * from './SearchModal';
export * from './SearchInput';
export * from './SearchContainer';
@@ -4,9 +4,13 @@ import React from 'react';
import type { LinkProps } from '../primitives';
export interface SearchState {
// URL-backed state
query: string;
ask: boolean;
global: boolean;
// Local UI state
open: boolean;
}
// KeyMap needs to be statically defined to avoid `setRawState` being redefined on every render.
@@ -28,13 +32,24 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
history: 'replace',
});
// Separate local state for open (not synchronized with URL)
// Default to true if there's already a query in the URL
const [open, setIsOpen] = React.useState(() => {
return rawState?.q !== null;
});
const state = React.useMemo<SearchState | null>(() => {
if (rawState === null || rawState.q === null) {
return null;
}
return { query: rawState.q, ask: !!rawState.ask, global: !!rawState.global };
}, [rawState]);
return {
query: rawState.q,
ask: !!rawState.ask,
global: !!rawState.global,
open: open,
};
}, [rawState, open]);
const stateRef = React.useRef(state);
React.useLayoutEffect(() => {
@@ -50,12 +65,17 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
}
if (update === null) {
setIsOpen(false);
return setRawState({
q: null,
ask: null,
global: null,
});
}
// Update the local state
setIsOpen(update.open);
return setRawState({
q: update.query,
ask: update.ask ? true : null,
@@ -78,8 +98,8 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
(query) => {
const searchParams = new URLSearchParams();
searchParams.set('q', query.query ?? '');
query.ask ? searchParams.set('ask', 'on') : searchParams.delete('ask');
query.global ? searchParams.set('global', 'on') : searchParams.delete('global');
query.ask ? searchParams.set('ask', 'true') : searchParams.delete('ask');
query.global ? searchParams.set('global', 'true') : searchParams.delete('global');
return {
href: `?${searchParams.toString()}`,
prefetch: false,
@@ -89,6 +109,7 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
query: '',
ask: false,
global: false,
open: true,
...(prev ?? {}),
...query,
}));
@@ -95,6 +95,9 @@ export async function generateSiteLayoutViewport(context: GitBookSiteContext): P
? 'dark light'
: 'light dark'
: customization.themes.default,
width: 'device-width',
initialScale: 1,
maximumScale: 1,
};
}
@@ -11,7 +11,7 @@ import { useScrollPage } from '@/components/hooks';
export function PageClientLayout(props: { withSections?: boolean }) {
// We use this hook in the page layout to ensure the elements for the blocks
// are rendered before we scroll to a hash or to the top of the page
useScrollPage({ scrollMarginTop: props.withSections ? 48 : undefined });
useScrollPage({ scrollMarginTop: props.withSections ? 108 : 64 });
useStripFallbackQueryParam();
return null;
@@ -71,14 +71,17 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
redirectPathname,
]);
for (const source of redirectSources) {
const resolvedSiteRedirect = await getDataOrNull(
context.dataFetcher.getSiteRedirectBySource({
organizationId,
siteId: site.id,
source,
siteShareKey: shareKey,
})
);
// We try to resolve the site redirect
const resolvedSiteRedirect =
source.length < 512 &&
(await getDataOrNull(
context.dataFetcher.getSiteRedirectBySource({
organizationId,
siteId: site.id,
source,
siteShareKey: shareKey,
})
));
if (resolvedSiteRedirect) {
return redirect(linker.toLinkForContent(resolvedSiteRedirect.target));
}
@@ -4,19 +4,18 @@ import React from 'react';
import { Footer } from '@/components/Footer';
import { Header, HeaderLogo } from '@/components/Header';
import { SearchButton, SearchModal } from '@/components/Search';
import { TableOfContents } from '@/components/TableOfContents';
import { CONTAINER_STYLE } from '@/components/layout';
import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@/lib/env';
import { AIChat } from '../AIChat';
import { AIChatButton } from '../AIChat';
import { Announcement } from '../Announcement';
import { SpacesDropdown } from '../Header/SpacesDropdown';
import { InsightsProvider } from '../Insights';
import { SearchContainer } from '../Search';
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
import { CurrentContentProvider } from '../hooks';
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
@@ -50,10 +49,22 @@ export function SpaceLayout(props: {
customization.footer.logo ||
customization.footer.groups?.length;
// TODO: remove aiSearch and optional chain once the cache has been fully updated (after 11/07/2025)
const withAIChat =
context.customization.ai?.mode === CustomizationAIMode.Assistant &&
(context.site.id === 'site_p4Xo4' || context.site.id === 'site_JOVzv');
const aiMode = customization.ai?.mode;
const searchAndAI = (
<div className="flex grow items-center gap-2">
<React.Suspense fallback={null}>
<SearchContainer
aiMode={aiMode}
isMultiVariants={siteSpaces.length > 1}
spaceTitle={siteSpace.title}
/>
</React.Suspense>
{aiMode === CustomizationAIMode.Assistant ? (
<AIChatButton trademark={customization.trademark.enabled} />
) : null}
</div>
);
return (
<SpaceLayoutContextProvider basePath={context.linker.toPathInSpace('')}>
@@ -71,15 +82,13 @@ export function SpaceLayout(props: {
enabled={withTracking}
appURL={GITBOOK_APP_URL}
apiHost={GITBOOK_API_PUBLIC_URL}
visitorCookieTrackingEnabled={context.customization.insights?.trackingCookie}
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
>
<Announcement context={context} />
<Header
withTopHeader={withTopHeader}
withAIChat={withAIChat}
context={context}
/>
{withAIChat ? <AIChat /> : null}
<Header withTopHeader={withTopHeader} context={context} search={searchAndAI} />
{aiMode === CustomizationAIMode.Assistant ? (
<AIChat trademark={customization.trademark.enabled} />
) : null}
<div className="scroll-nojump">
<div className="motion-safe:transition-all motion-safe:duration-300 lg:chat-open:mr-80 xl:chat-open:mr-96">
<div
@@ -103,7 +112,7 @@ export function SpaceLayout(props: {
className={tcls(
'hidden',
'pr-4',
'lg:flex',
'md:flex',
'grow-0',
'flex-wrap',
'dark:shadow-light/1',
@@ -117,27 +126,7 @@ export function SpaceLayout(props: {
innerHeader={
// displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC.
<>
{!withTopHeader && (
<div className={tcls('hidden', 'lg:block')}>
<React.Suspense fallback={null}>
<SearchButton>
<span className={tcls('flex-1')}>
{t(
getSpaceLanguage(customization),
// TODO: remove aiSearch and optional chain once the cache has been fully updated (after 11/07/2025)
customization.aiSearch
?.enabled ||
customization.ai?.mode !==
CustomizationAIMode.None
? 'search_or_ask'
: 'search'
)}
...
</span>
</SearchButton>
</React.Suspense>
</div>
)}
{!withTopHeader && searchAndAI}
{!withTopHeader && withSections && sections && (
<SiteSectionList
className={tcls('hidden', 'lg:block')}
@@ -168,19 +157,6 @@ export function SpaceLayout(props: {
</div>
{withFooter ? <Footer context={context} /> : null}
<React.Suspense fallback={null}>
<SearchModal
spaceTitle={siteSpace.title}
// TODO: remove aiSearch and optional chain once the cache has been fully updated (after 11/07/2025)
withAsk={
customization.aiSearch?.enabled ||
customization.ai?.mode !== CustomizationAIMode.None
}
withAIChat={withAIChat}
isMultiVariants={isMultiVariants}
/>
</React.Suspense>
</InsightsProvider>
</CurrentContentProvider>
</SpaceLayoutContextProvider>
@@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
export function useIsMobile(breakpoint = 640): boolean {
const [isMobile, setIsMobile] = React.useState(false);
React.useEffect(() => {
// Use matchMedia for a single, efficient listener.
// 0.02px fudge keeps it safely below the breakpoint (mirrors Tailwinds 639.98px).
const query = `(max-width: ${breakpoint - 0.02}px)`;
const media = window.matchMedia(query);
// Sync initial state if it differs (e.g., on hydration).
setIsMobile(media.matches);
const handleChange = (event: MediaQueryListEvent | MediaQueryList) => {
setIsMobile(event.matches);
};
media.addEventListener('change', handleChange);
return () => {
media.removeEventListener('change', handleChange);
};
}, [breakpoint]);
return isMobile;
}
@@ -1,6 +1,6 @@
'use client';
import type { ButtonHTMLAttributes, HTMLAttributeAnchorTarget, HTMLAttributes } from 'react';
import * as React from 'react';
import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -11,14 +11,17 @@ import { Tooltip } from './Tooltip';
type ButtonProps = {
href?: string;
variant?: 'primary' | 'secondary' | 'blank';
variant?: 'primary' | 'secondary' | 'blank' | 'header';
icon?: IconName | React.ReactNode;
iconOnly?: boolean;
size?: 'default' | 'medium' | 'small';
size?: 'default' | 'medium' | 'small' | 'xsmall';
className?: ClassValue;
label?: string;
label?: string | React.ReactNode;
trailing?: React.ReactNode;
children?: React.ReactNode;
active?: boolean;
} & LinkInsightsProps &
HTMLAttributes<HTMLElement>;
React.HTMLAttributes<HTMLElement>;
export const variantClasses = {
primary: [
@@ -26,19 +29,19 @@ export const variantClasses = {
'text-contrast-primary-solid',
'hover:bg-primary-solid-hover',
'hover:text-contrast-primary-solid-hover',
'ring-0',
'contrast-more:ring-1',
'border-0',
'contrast-more:border-1',
],
blank: [
'bg-transparent',
'text-tint',
'ring-0',
'shadow-none',
'border-0',
'!shadow-none',
'hover:bg-primary-hover',
'hover:text-primary',
'hover:text-primary-strong',
'hover:scale-1',
'hover:shadow-none',
'contrast-more:bg-tint-subtle',
'depth-subtle:hover:translate-y-0',
],
secondary: [
'bg-tint',
@@ -49,42 +52,76 @@ export const variantClasses = {
'hover:text-primary',
'contrast-more:bg-tint-subtle',
],
header: [
'bg-tint-base text-tint',
'theme-clean:hover:bg-tint-subtle',
'theme-bold:bg-header-link/2',
'theme-bold:text-header-link',
'theme-bold:!shadow-none',
'theme-bold:ring-header-link/4',
'theme-bold:hover:bg-header-link/3',
'theme-bold:hover:text-header-link',
'theme-bold:hover:shadow-none',
'theme-bold:hover:ring-header-link/5',
'theme-bold:contrast-more:bg-header-background',
'theme-bold:contrast-more:text-header-link',
'theme-bold:contrast-more:ring-header-link',
'theme-bold:contrast-more:hover:ring-header-link',
],
};
export function Button({
href,
variant = 'primary',
size = 'default',
className,
insights,
target,
label,
icon,
iconOnly = false,
...rest
}: ButtonProps & ButtonHTMLAttributes<HTMLButtonElement> & { target?: HTMLAttributeAnchorTarget }) {
const sizes = {
default: ['text-base', 'font-semibold', 'px-5', 'py-2', 'circular-corners:px-6'],
medium: ['text-sm', 'px-3.5', 'py-1.5', 'circular-corners:px-4'],
small: ['text-xs', 'py-2', iconOnly ? 'px-2' : 'px-3'],
};
const activeClasses = {
primary: 'bg-primary-solid-hover',
blank: 'bg-primary-active text-primary-strong font-medium hover:text-primary-strong hover:bg-primary-active',
secondary: 'bg-tint-active',
header: 'bg-header-link/3',
};
const sizeClasses = sizes[size] || sizes.default;
export const Button = React.forwardRef<
HTMLButtonElement | HTMLAnchorElement,
ButtonProps &
React.ButtonHTMLAttributes<HTMLButtonElement> & { target?: React.HTMLAttributeAnchorTarget }
>(
(
{
href,
variant = 'primary',
size = 'default',
className,
insights,
target,
label,
icon,
iconOnly = false,
children,
active,
trailing,
...rest
},
ref
) => {
const sizes = {
default: ['text-base', 'font-semibold', 'px-5', 'py-2', 'circular-corners:px-6'],
medium: ['text-sm', 'px-3.5', 'py-1.5', 'circular-corners:px-4'],
small: ['text-xs', 'py-2', iconOnly ? 'px-2' : 'px-3'],
xsmall: ['text-xs', 'py-1', iconOnly ? 'px-1.5' : 'px-2'],
};
const domClassName = tcls(variantClasses[variant], sizeClasses, className);
const buttonOnlyClassNames = useClassnames(['ButtonStyles']);
const sizeClasses = sizes[size] || sizes.default;
if (href) {
return (
<Link
href={href}
className={domClassName}
classNames={['ButtonStyles']}
insights={insights}
aria-label={label}
target={target}
{...rest}
>
const domClassName = tcls(
variantClasses[variant],
sizeClasses,
active && activeClasses[variant],
className
);
const buttonOnlyClassNames = useClassnames(['ButtonStyles']);
const content = (
<>
{icon ? (
typeof icon === 'string' ? (
<Icon icon={icon as IconName} className={tcls('size-[1em]')} />
@@ -93,27 +130,38 @@ export function Button({
)
) : null}
{iconOnly ? null : label}
</Link>
</>
);
if (href) {
return (
<Link
ref={ref as React.Ref<HTMLAnchorElement>}
href={href}
className={domClassName}
classNames={['ButtonStyles']}
insights={insights}
aria-label={label?.toString()}
target={target}
{...rest}
>
{content}
</Link>
);
}
const button = (
<button
ref={ref as React.Ref<HTMLButtonElement>}
type="button"
className={tcls(buttonOnlyClassNames, domClassName)}
aria-label={label?.toString()}
{...rest}
>
{content}
</button>
);
return iconOnly && label ? <Tooltip label={label}>{button}</Tooltip> : button;
}
const button = (
<button
type="button"
className={tcls(buttonOnlyClassNames, domClassName)}
aria-label={label}
{...rest}
>
{icon ? (
typeof icon === 'string' ? (
<Icon icon={icon as IconName} className={tcls('size-[1em]')} />
) : (
icon
)
) : null}
{iconOnly ? null : label}
</button>
);
return iconOnly ? <Tooltip label={label}>{button}</Tooltip> : button;
}
);
@@ -2,12 +2,13 @@
import { Icon } from '@gitbook/icons';
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
import { useState } from 'react';
import { createContext, useCallback, useContext, useState } from 'react';
import { type ClassValue, tcls } from '@/lib/tailwind';
import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu';
import { assert } from 'ts-essentials';
import { Link, type LinkInsightsProps } from '.';
export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit<
@@ -15,6 +16,14 @@ export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit<
'ref'
>;
const DropdownMenuContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
}>({
open: false,
setOpen: () => {},
});
/**
* Button with a dropdown.
*/
@@ -27,48 +36,66 @@ export function DropdownMenu(props: {
className?: ClassValue;
/** Open the dropdown on hover */
openOnHover?: boolean;
/**
* Side of the dropdown
* @default "bottom"
*/
side?: RadixDropdownMenu.DropdownMenuContentProps['side'];
/**
* Alignment of the dropdown
* @default "start"
*/
align?: RadixDropdownMenu.DropdownMenuContentProps['align'];
}) {
const { button, children, className, openOnHover = false } = props;
const {
button,
children,
className,
openOnHover = false,
side = 'bottom',
align = 'start',
} = props;
const [hovered, setHovered] = useState(false);
const [clicked, setClicked] = useState(false);
const [open, setOpen] = useState(false);
const isOpen = openOnHover ? open || hovered : open;
return (
<RadixDropdownMenu.Root
modal={false}
open={openOnHover ? clicked || hovered : clicked}
onOpenChange={setClicked}
>
<RadixDropdownMenu.Trigger
asChild
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => (openOnHover ? setClicked(!clicked) : null)}
className="group/dropdown"
>
{button}
</RadixDropdownMenu.Trigger>
<RadixDropdownMenu.Portal>
<RadixDropdownMenu.Content
data-testid="dropdown-menu"
hideWhenDetached
collisionPadding={8}
<DropdownMenuContext.Provider value={{ open: isOpen, setOpen }}>
<RadixDropdownMenu.Root modal={false} open={isOpen} onOpenChange={setOpen}>
<RadixDropdownMenu.Trigger
asChild
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
align="start"
className="z-40 animate-scaleIn pt-2"
onClick={() => (openOnHover ? setOpen(!open) : null)}
className="group/dropdown"
>
<div
className={tcls(
'flex max-h-80 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto rounded-lg straight-corners:rounded-sm bg-tint-base p-2 shadow-lg ring-1 ring-tint-subtle sm:min-w-52 sm:max-w-80',
className
)}
{button}
</RadixDropdownMenu.Trigger>
<RadixDropdownMenu.Portal>
<RadixDropdownMenu.Content
data-testid="dropdown-menu"
hideWhenDetached
collisionPadding={8}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
align={align}
side={side}
className="z-40 animate-scaleIn border-tint pt-2"
>
{children}
</div>
</RadixDropdownMenu.Content>
</RadixDropdownMenu.Portal>
</RadixDropdownMenu.Root>
<div
className={tcls(
'flex max-h-80 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto circular-corners:rounded-xl rounded-md straight-corners:rounded-none border border-tint bg-tint-base p-2 shadow-lg sm:min-w-52 sm:max-w-80',
className
)}
>
{children}
</div>
</RadixDropdownMenu.Content>
</RadixDropdownMenu.Portal>
</RadixDropdownMenu.Root>
</DropdownMenuContext.Provider>
);
}
@@ -116,16 +143,17 @@ export function DropdownButton(props: {
export function DropdownMenuItem(
props: {
href?: string;
target?: React.HTMLAttributeAnchorTarget;
active?: boolean;
className?: ClassValue;
children: React.ReactNode;
} & LinkInsightsProps &
RadixDropdownMenu.DropdownMenuItemProps
) {
const { children, active = false, href, className, insights, ...rest } = props;
const { children, active = false, href, className, insights, target, ...rest } = props;
const itemClassName = tcls(
'rounded straight-corners:rounded-sm px-3 py-1 text-sm flex gap-2 items-center',
'rounded straight-corners:rounded-sm circular-corners:rounded-lg px-3 py-1 text-sm flex gap-2 items-center',
active
? 'bg-primary text-primary-strong data-[highlighted]:bg-primary-hover'
: 'data-[highlighted]:bg-tint-hover',
@@ -137,7 +165,7 @@ export function DropdownMenuItem(
if (href) {
return (
<RadixDropdownMenu.Item {...rest} asChild>
<Link href={href} insights={insights} className={itemClassName}>
<Link href={href} insights={insights} className={itemClassName} target={target}>
{children}
</Link>
</RadixDropdownMenu.Item>
@@ -174,3 +202,12 @@ export function DropdownSubMenu(props: { children: React.ReactNode; label: React
</RadixDropdownMenu.Sub>
);
}
/**
* Hook to close the dropdown menu.
*/
export function useDropdownMenuClose() {
const context = useContext(DropdownMenuContext);
assert(context, 'DropdownMenuContext not found');
return useCallback(() => context.setOpen(false), [context]);
}
@@ -0,0 +1,52 @@
'use client';
import { type ClassValue, tcls } from '@/lib/tailwind';
import * as React from 'react';
function getOperatingSystem() {
const platform = navigator.platform.toLowerCase();
if (platform.includes('mac')) return 'mac';
if (platform.includes('win')) return 'win';
return 'win';
}
export function KeyboardShortcut(props: { keys: string[]; className?: ClassValue }) {
const { keys, className } = props;
const [operatingSystem, setOperatingSystem] = React.useState<string | null>(null);
React.useEffect(() => {
setOperatingSystem(getOperatingSystem());
}, []);
return (
<div
aria-busy={operatingSystem === null ? 'true' : undefined}
className={tcls(
'shortcut -mr-1 hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt",_"case"] contrast-more:text-tint-strong md:flex',
operatingSystem
? 'motion-safe:animate-fadeIn motion-reduce:opacity-100'
: 'opacity-0'
)}
>
{keys.map((key) => (
<kbd
key={key}
className={tcls(
'flex h-5 min-w-5 items-center justify-center rounded-md border border-tint-subtle px-1',
key === 'mod'
? operatingSystem === 'mac'
? 'text-sm'
: 'text-xs'
: 'uppercase',
className
)}
>
{key === 'mod' ? (operatingSystem === 'mac' ? '⌘' : 'Ctrl') : key}
</kbd>
))}
</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 { isExternalLink } from '../utils/link';
import { type DesignTokenName, useClassnames } from './StyleProvider';
// Props from Next, which includes NextLinkProps and all the things anchor elements support.
@@ -40,6 +41,26 @@ export const LinkSettingsContext = React.createContext<{
externalLinksTarget: SiteExternalLinksTarget.Self,
});
/**
* Get the target and rel props for a link based on the provided props and context.
*/
function getTargetProps(
props: Pick<LinkProps, 'href' | 'rel' | 'target'>,
context: {
externalLinksTarget: SiteExternalLinksTarget;
isExternal: boolean;
}
) {
const target =
props.target ??
(context.isExternal && context.externalLinksTarget === SiteExternalLinksTarget.Blank
? '_blank'
: undefined);
// Automatically set rel if target is _blank, or use the specified rel.
const rel = props.rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined);
return { target, rel };
}
/**
* Low-level Link component that handles navigation to external urls.
* It does not contain any styling.
@@ -52,28 +73,33 @@ export const Link = React.forwardRef(function Link(
const { externalLinksTarget } = React.useContext(LinkSettingsContext);
const trackEvent = useTrackEvent();
const forwardedClassNames = useClassnames(classNames || []);
// Use a real anchor tag for external links,s and a Next.js Link for internal links.
// If we use a NextLink for external links, Nextjs won't rerender the top-level layouts.
const isExternal = URL.canParse ? URL.canParse(props.href) : props.href.startsWith('http');
const isExternal = isExternalLink(href);
const { target, rel } = getTargetProps(props, { externalLinksTarget, isExternal });
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
const isExternalWithOrigin = isExternalLink(href, window.location.origin);
if (insights) {
trackEvent(insights, undefined, {
immediate: isExternal,
});
trackEvent(insights, undefined, { immediate: isExternalWithOrigin });
}
if (
isExternalLink(href, window.location.origin) &&
// When the page is embedded in an iframe, for security reasons other urls cannot be opened.
// In this case, we open the link in a new tab.
(window.self !== window.top ||
// If the site is configured to open links in a new tab
externalLinksTarget === SiteExternalLinksTarget.Blank)
) {
const isInIframe = window.self !== window.top;
// When the page is embedded in an iframe
// for security reasons other urls cannot be opened.
if (isInIframe && isExternalWithOrigin) {
event.preventDefault();
window.open(href, '_blank');
window.open(href, '_blank', 'noopener noreferrer');
} else if (isExternal && !event.ctrlKey && !event.metaKey) {
// The external logic server-side is limited
// so we use the client-side logic to determine the real target
// by default the target is "_self".
const { target = '_self' } = getTargetProps(props, {
externalLinksTarget,
isExternal: isExternalWithOrigin,
});
event.preventDefault();
window.open(href, target, rel);
}
domProps.onClick?.(event);
@@ -81,7 +107,7 @@ export const Link = React.forwardRef(function Link(
// We test if the link is external, without comparing to the origin
// as this will be rendered on the server and it could result in a mismatch.
if (isExternalLink(href)) {
if (isExternal) {
return (
<a
ref={ref}
@@ -89,9 +115,8 @@ export const Link = React.forwardRef(function Link(
{...domProps}
href={href}
onClick={onClick}
{...(externalLinksTarget === SiteExternalLinksTarget.Blank
? { target: '_blank', rel: 'noopener noreferrer' }
: {})}
target={target}
rel={rel}
>
{children}
</a>
@@ -153,27 +178,3 @@ export const LinkOverlay = React.forwardRef(function LinkOverlay(
</Link>
);
});
/**
* Check if a link is external, compared to an origin.
*/
function isExternalLink(href: string, origin: string | null = null) {
if (!URL.canParse) {
// If URL.canParse is not available, we quickly check if it looks like a URL
return href.startsWith('http');
}
if (!URL.canParse(href)) {
// If we can't parse the href, we consider it a relative path
return false;
}
if (!origin) {
// If origin is not provided, we consider the link external
return true;
}
// If the url points to the same origin, we consider it internal
const parsed = new URL(href);
return parsed.origin !== origin;
}
@@ -4,30 +4,28 @@ import * as RadixPopover from '@radix-ui/react-popover';
export function Popover(props: {
children: React.ReactNode;
content?: string | React.ReactNode;
rootProps?: RadixPopover.PopoverProps;
triggerProps?: RadixPopover.PopoverTriggerProps;
contentProps?: RadixPopover.PopoverContentProps;
arrow?: boolean;
className?: string;
}) {
const { children, content, triggerProps, contentProps, arrow = false, className } = props;
const { children, content, rootProps, triggerProps, contentProps, arrow = false } = props;
return (
<RadixPopover.Root>
<RadixPopover.Trigger asChild {...triggerProps}>
{children}
</RadixPopover.Trigger>
<RadixPopover.Root {...rootProps}>
<RadixPopover.Trigger {...triggerProps}>{children}</RadixPopover.Trigger>
<RadixPopover.Portal>
<RadixPopover.Content
collisionPadding={16}
sideOffset={4}
{...contentProps}
collisionPadding={contentProps?.collisionPadding ?? 16}
sideOffset={contentProps?.sideOffset ?? 4}
className={tcls(
'z-50 max-w-xs animate-scaleIn overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-3 text-sm text-tint shadow-md outline-none ring-1 ring-tint',
className
'z-50 max-h-[var(--radix-popover-content-available-height)] max-w-xs animate-scaleIn overflow-y-auto overflow-x-hidden circular-corners:rounded-3xl rounded-corners:rounded-md bg-tint px-4 py-3 text-sm text-tint depth-subtle:shadow-lg shadow-tint outline-none ring-1 ring-tint transition-all empty:hidden',
contentProps?.className
)}
style={{
maxHeight: 'var(--radix-popover-content-available-height)',
...contentProps?.style,
}}
{...contentProps}
>
{content}
{arrow && (
@@ -7,6 +7,7 @@ export function Tooltip(props: {
providerProps?: RadixTooltip.TooltipProviderProps;
triggerProps?: RadixTooltip.TooltipTriggerProps;
contentProps?: RadixTooltip.TooltipContentProps;
rootProps?: RadixTooltip.TooltipProps;
arrow?: boolean;
className?: string;
}) {
@@ -16,13 +17,14 @@ export function Tooltip(props: {
providerProps,
triggerProps,
contentProps,
rootProps,
arrow = false,
className,
} = props;
return (
<RadixTooltip.Provider delayDuration={300} {...providerProps}>
<RadixTooltip.Root>
<RadixTooltip.Root {...rootProps}>
<RadixTooltip.Trigger asChild {...triggerProps}>
{children}
</RadixTooltip.Trigger>
@@ -2,32 +2,31 @@ import type { ClassValue } from '@/lib/tailwind';
export const ButtonStyles = [
'button',
'group/button',
'inline-flex',
'items-center',
'gap-2',
'rounded-md',
'straight-corners:rounded-none',
'circular-corners:rounded-full',
// 'place-self-start',
'ring-1',
'ring-tint',
'hover:ring-tint-hover',
'border',
'border-tint',
'hover:border-tint-hover',
'shadow-sm',
'depth-subtle:shadow-sm',
'depth-subtle:hover:shadow-md',
'depth-subtle:focus-visible:shadow-md',
'active:shadow-none',
'shadow-tint',
'dark:shadow-tint-1',
'hover:shadow-md',
'active:shadow-none',
'depth-flat:shadow-none',
'contrast-more:ring-tint-12',
'contrast-more:hover:ring-2',
'contrast-more:hover:ring-tint-12',
'contrast-more:border-tint-12',
'contrast-more:hover:border-2',
'contrast-more:hover:border-tint-12',
'hover:scale-104',
'depth-flat:hover:scale-100',
'active:scale-100',
'depth-subtle:hover:-translate-y-px',
'depth-subtle:focus-visible:-translate-y-px',
'transition-all',
'grow-0',
@@ -0,0 +1,7 @@
import type { GitBookSiteContext } from '@/lib/context';
import { CustomizationAIMode } from '@gitbook/api';
// TODO: remove aiSearch and optional chain once the cache has been fully updated (after 11/07/2025)
export const isAIChatEnabled = (context: GitBookSiteContext) =>
context.customization.ai?.mode === CustomizationAIMode.Assistant &&
(context.site.id === 'site_p4Xo4' || context.site.id === 'site_JOVzv');
@@ -0,0 +1,23 @@
/**
* Check if a link is external, compared to an origin.
*/
export function isExternalLink(href: string, origin: string | null = null) {
if (!URL.canParse) {
// If URL.canParse is not available, we quickly check if it looks like a URL
return href.startsWith('http');
}
if (!URL.canParse(href)) {
// If we can't parse the href, we consider it a relative path
return false;
}
if (!origin) {
// If origin is not provided, we consider the link external
return true;
}
// If the url points to the same origin, we consider it internal
const parsed = new URL(href);
return parsed.origin !== origin;
}
+13 -2
View File
@@ -14,6 +14,7 @@ export const de = {
search_results_count: '${1} Ergebnisse',
search_scope_space: '${1}',
search_scope_all: 'Alle Inhalte',
ask: 'Fragen',
search_ask: 'Fragen "${1}"',
search_ask_description: 'Finden Sie die Antwort mit AI',
search_ask_sources: 'Quellen',
@@ -64,7 +65,8 @@ export const de = {
link_tooltip_external_link: 'Externe Verlinkung zu',
link_tooltip_page_anchor: 'Zum Abschnitt springen',
open_in_new_tab: 'In neuem Tab öffnen',
ai_chat_assistant_name: 'Docs-Assistent',
ai_chat_assistant_name: 'GitBook-Assistent',
ai_chat_assistant_name_unbranded: 'KI-Assistent',
ai_chat_assistant_description: 'Ich helfe Ihnen bei der Dokumentation.',
ai_chat_assistant_greeting_morning: 'Guten Morgen',
ai_chat_assistant_greeting_afternoon: 'Guten Tag',
@@ -77,7 +79,7 @@ export const de = {
ai_chat_error: 'Etwas ist schief gelaufen.',
ai_chat_context_title: 'Basierend auf Ihrem Kontext',
ai_chat_context_description:
'Der Docs-Assistent verwendet KI und Ihren Kontext, um Antworten zu generieren und Aktionen durchzuführen.',
'Der Assistent verwendet KI und Ihren Kontext, um Antworten zu generieren und Aktionen durchzuführen.',
ai_chat_context_pages_youve_read: 'Seiten, die Sie gelesen haben',
ai_chat_context_info_provided_by_the_site: 'Von der Website bereitgestellte Informationen',
ai_chat_context_previous_messages: 'Vorherige Nachrichten',
@@ -92,4 +94,13 @@ export const de = {
ai_chat_tools_listed_pages: 'Docs durchsucht',
ai_chat_tools_read_page: 'Seite ${1} gelesen',
ai_chat_tools_mcp_tool: 'Aufgerufen ${1}',
ai_chat_ask: 'Frage ${1}',
ai_chat_ask_about_page: 'Frage ${1} zu dieser Seite',
copy_for_llms: 'Für LLMs kopieren',
copy_page_markdown: 'Seite als Markdown für LLMs kopieren',
copy_page: 'Seite kopieren',
view_page_markdown: 'Als Markdown anzeigen',
view_page_plaintext: 'Diese Seite als Klartext anzeigen',
open_in: 'Öffnen in ${1}',
open_in_llms_pre_prompt: 'Lies ${1} und beantworte Fragen zum Inhalt.',
};
+13 -2
View File
@@ -14,6 +14,7 @@ export const en = {
search_results_count: '${1} results',
search_scope_space: '${1}',
search_scope_all: 'All content',
ask: 'Ask',
search_ask: 'Ask "${1}"',
search_ask_description: 'Find the answer with AI',
search_ask_sources: 'Sources',
@@ -62,7 +63,8 @@ export const en = {
link_tooltip_external_link: 'External link to',
link_tooltip_page_anchor: 'Jump to section',
open_in_new_tab: 'Open in new tab',
ai_chat_assistant_name: 'Docs Assistant',
ai_chat_assistant_name: 'GitBook Assistant',
ai_chat_assistant_name_unbranded: 'AI Assistant',
ai_chat_assistant_description: "I'm here to help you with the docs.",
ai_chat_assistant_greeting_morning: 'Good morning',
ai_chat_assistant_greeting_afternoon: 'Good afternoon',
@@ -75,7 +77,7 @@ export const en = {
ai_chat_error: 'Something went wrong.',
ai_chat_context_title: 'Based on your context',
ai_chat_context_description:
'Docs Assistant uses AI and your context to generate answers and perform actions.',
'The assistant uses AI and your context to generate answers and perform actions.',
ai_chat_context_pages_youve_read: "Pages you've read",
ai_chat_context_info_provided_by_the_site: 'Info provided by the site',
ai_chat_context_previous_messages: 'Previous messages',
@@ -90,4 +92,13 @@ export const en = {
ai_chat_tools_listed_pages: 'Browsed the docs',
ai_chat_tools_read_page: 'Read page ${1}',
ai_chat_tools_mcp_tool: 'Called ${1}',
ai_chat_ask: 'Ask ${1}',
ai_chat_ask_about_page: 'Ask ${1} about this page',
copy_for_llms: 'Copy for LLMs',
copy_page_markdown: 'Copy page as Markdown for LLMs',
copy_page: 'Copy page',
view_page_markdown: 'View as Markdown',
view_page_plaintext: 'View this page as plain text',
open_in: 'Open in ${1}',
open_in_llms_pre_prompt: 'Read ${1} and answer questions about the content.',
};
+13 -2
View File
@@ -16,6 +16,7 @@ export const es: TranslationLanguage = {
search_results_count: '${1} resultados',
search_scope_space: '${1}',
search_scope_all: 'Todo el contenido',
ask: 'Preguntar',
search_ask: 'Preguntar "${1}"',
search_ask_description: 'Encuentra la respuesta con IA',
search_ask_sources: 'Fuentes',
@@ -66,7 +67,8 @@ export const es: TranslationLanguage = {
link_tooltip_external_link: 'Enlace externo a',
link_tooltip_page_anchor: 'Saltar a la sección',
open_in_new_tab: 'Abrir en una nueva pestaña',
ai_chat_assistant_name: 'Asistente de Docs',
ai_chat_assistant_name: 'Asistente de GitBook',
ai_chat_assistant_name_unbranded: 'Asistente de IA',
ai_chat_assistant_description: 'Estoy aquí para ayudarte con la documentación.',
ai_chat_assistant_greeting_morning: 'Buenos días',
ai_chat_assistant_greeting_afternoon: 'Buenas tardes',
@@ -79,7 +81,7 @@ export const es: TranslationLanguage = {
ai_chat_error: 'Algo salió mal.',
ai_chat_context_title: 'Basado en tu contexto',
ai_chat_context_description:
'El Asistente de Docs usa IA y tu contexto para generar respuestas y realizar acciones.',
'El asistente usa IA y tu contexto para generar respuestas y realizar acciones.',
ai_chat_context_pages_youve_read: 'Páginas que has leído',
ai_chat_context_info_provided_by_the_site: 'Información proporcionada por el sitio',
ai_chat_context_previous_messages: 'Mensajes anteriores',
@@ -94,4 +96,13 @@ export const es: TranslationLanguage = {
ai_chat_tools_listed_pages: 'Exploró los docs',
ai_chat_tools_read_page: 'Leyó la página ${1}',
ai_chat_tools_mcp_tool: 'Llamó a ${1}',
ai_chat_ask: 'Preguntar ${1}',
ai_chat_ask_about_page: 'Preguntar a ${1} sobre esta página',
copy_for_llms: 'Copiar para LLMs',
copy_page_markdown: 'Copiar página como Markdown para LLMs',
copy_page: 'Copiar página',
view_page_markdown: 'Ver como Markdown',
view_page_plaintext: 'Ver esta página como texto plano',
open_in: 'Abrir en ${1}',
open_in_llms_pre_prompt: 'Lee ${1} y responde preguntas sobre el contenido.',
};
+13 -2
View File
@@ -16,6 +16,7 @@ export const fr: TranslationLanguage = {
search_results_count: '${1} résultats',
search_scope_space: '${1}',
search_scope_all: 'Tout le contenu',
ask: 'Demander',
search_ask: 'Demander "${1}"',
search_ask_description: "Trouvez la réponse avec l'IA",
search_ask_sources: 'Sources',
@@ -64,7 +65,8 @@ export const fr: TranslationLanguage = {
link_tooltip_external_link: 'Lien externe à',
link_tooltip_page_anchor: 'Sauter à la section',
open_in_new_tab: 'Ouvrir dans un nouvel onglet',
ai_chat_assistant_name: 'Assistant Docs',
ai_chat_assistant_name: 'Assistant GitBook',
ai_chat_assistant_name_unbranded: 'Assistant IA',
ai_chat_assistant_description: 'Je suis là pour vous aider avec la documentation.',
ai_chat_assistant_greeting_morning: 'Bonjour',
ai_chat_assistant_greeting_afternoon: 'Bon après-midi',
@@ -77,7 +79,7 @@ export const fr: TranslationLanguage = {
ai_chat_error: 'Une erreur est survenue.',
ai_chat_context_title: 'Basé sur votre contexte',
ai_chat_context_description:
"L'Assistant Docs utilise l'IA et votre contexte pour générer des réponses et effectuer des actions.",
"L'assistant utilise l'IA et votre contexte pour générer des réponses et effectuer des actions.",
ai_chat_context_pages_youve_read: 'Pages que vous avez lues',
ai_chat_context_info_provided_by_the_site: 'Informations fournies par le site',
ai_chat_context_previous_messages: 'Messages précédents',
@@ -92,4 +94,13 @@ export const fr: TranslationLanguage = {
ai_chat_tools_listed_pages: 'Parcouru les docs',
ai_chat_tools_read_page: 'Lu la page ${1}',
ai_chat_tools_mcp_tool: 'Appelé ${1}',
ai_chat_ask: 'Demander à ${1}',
ai_chat_ask_about_page: 'Demander à ${1} au sujet de cette page',
copy_for_llms: 'Copier pour les LLMs',
copy_page_markdown: 'Copier la page en Markdown pour les LLMs',
copy_page: 'Copier la page',
view_page_markdown: 'Voir en Markdown',
view_page_plaintext: 'Voir cette page en texte brut',
open_in: 'Ouvrir dans ${1}',
open_in_llms_pre_prompt: 'Lis ${1} et réponds aux questions sur le contenu.',
};
+13 -2
View File
@@ -16,6 +16,7 @@ export const ja: TranslationLanguage = {
search_results_count: '${1}件の結果',
search_scope_space: '${1}',
search_scope_all: '全てのコンテンツ',
ask: '質問する',
search_ask: '"${1}" を質問する',
search_ask_description: 'AIで答えを見つける',
search_ask_sources: '情報源',
@@ -64,7 +65,8 @@ export const ja: TranslationLanguage = {
link_tooltip_external_link: '外部リンク先',
link_tooltip_page_anchor: 'ページ内リンク先',
open_in_new_tab: '新しいタブで開く',
ai_chat_assistant_name: 'ドキュメントアシスタント',
ai_chat_assistant_name: 'GitBookアシスタント',
ai_chat_assistant_name_unbranded: 'AIアシスタント',
ai_chat_assistant_description: 'ドキュメントについてお手伝いします。',
ai_chat_assistant_greeting_morning: 'おはようございます',
ai_chat_assistant_greeting_afternoon: 'こんにちは',
@@ -77,7 +79,7 @@ export const ja: TranslationLanguage = {
ai_chat_error: '何らかのエラーが発生しました。',
ai_chat_context_title: 'あなたのコンテキストに基づいて',
ai_chat_context_description:
'ドキュメントアシスタントはAIとあなたのコンテキストを使用して回答を生成し、アクションを実行します。',
'アシスタントはAIとあなたのコンテキストを使用して回答を生成し、アクションを実行します。',
ai_chat_context_pages_youve_read: '読んだページ',
ai_chat_context_info_provided_by_the_site: 'サイトから提供された情報',
ai_chat_context_previous_messages: '以前のメッセージ',
@@ -92,4 +94,13 @@ export const ja: TranslationLanguage = {
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} を読んで内容に関する質問に答えてください。',
};
+13 -2
View File
@@ -16,6 +16,7 @@ export const nl: TranslationLanguage = {
search_results_count: '${1} resultaten',
search_scope_space: '${1}',
search_scope_all: 'Alle inhoud',
ask: 'Vragen',
search_ask: 'Vraag "${1}"',
search_ask_description: 'Vind het antwoord met AI',
search_ask_sources: 'Bronnen',
@@ -64,7 +65,8 @@ export const nl: TranslationLanguage = {
link_tooltip_external_link: 'Externe link naar',
link_tooltip_page_anchor: 'Spring naar sectie',
open_in_new_tab: 'Open in nieuw tabblad',
ai_chat_assistant_name: 'Docs Assistent',
ai_chat_assistant_name: 'GitBook Assistent',
ai_chat_assistant_name_unbranded: 'AI Assistent',
ai_chat_assistant_description: 'Ik help je met de documentatie.',
ai_chat_assistant_greeting_morning: 'Goedemorgen',
ai_chat_assistant_greeting_afternoon: 'Goedemiddag',
@@ -77,7 +79,7 @@ export const nl: TranslationLanguage = {
ai_chat_error: 'Er is iets misgegaan.',
ai_chat_context_title: 'Gebaseerd op je context',
ai_chat_context_description:
'De Docs Assistent gebruikt AI en je context om antwoorden te genereren en acties uit te voeren.',
'De assistent gebruikt AI en je context om antwoorden te genereren en acties uit te voeren.',
ai_chat_context_pages_youve_read: "Pagina's die je hebt gelezen",
ai_chat_context_info_provided_by_the_site: 'Informatie verstrekt door de site',
ai_chat_context_previous_messages: 'Vorige berichten',
@@ -92,4 +94,13 @@ export const nl: TranslationLanguage = {
ai_chat_tools_listed_pages: 'Docs doorzocht',
ai_chat_tools_read_page: 'Pagina ${1} gelezen',
ai_chat_tools_mcp_tool: '${1} aangeroepen',
ai_chat_ask: 'Vraag ${1}',
ai_chat_ask_about_page: 'Vraag ${1} over deze pagina',
copy_for_llms: 'Kopiëren voor LLMs',
copy_page_markdown: 'Pagina kopiëren als Markdown voor LLMs',
copy_page: 'Pagina kopiëren',
view_page_markdown: 'Bekijk als Markdown',
view_page_plaintext: 'Bekijk deze pagina als platte tekst',
open_in: 'Open in ${1}',
open_in_llms_pre_prompt: 'Lees ${1} en beantwoord vragen over de inhoud.',
};
+13 -2
View File
@@ -16,6 +16,7 @@ export const no: TranslationLanguage = {
search_results_count: '${1} resultater',
search_scope_space: '${1}',
search_scope_all: 'Alt innhold',
ask: 'Spør',
search_ask: 'Spør "${1}"',
search_ask_description: 'Finn svaret med AI',
search_ask_sources: 'Kilder',
@@ -64,7 +65,8 @@ export const no: TranslationLanguage = {
link_tooltip_external_link: 'Ekstern lenke til',
link_tooltip_page_anchor: 'Hopp til seksjon',
open_in_new_tab: 'Åpne i ny fane',
ai_chat_assistant_name: 'Docs-assistent',
ai_chat_assistant_name: 'GitBook-assistent',
ai_chat_assistant_name_unbranded: 'AI-assistent',
ai_chat_assistant_description: 'Jeg er her for å hjelpe deg med docs.',
ai_chat_assistant_greeting_morning: 'God morgen',
ai_chat_assistant_greeting_afternoon: 'God ettermiddag',
@@ -77,7 +79,7 @@ export const no: TranslationLanguage = {
ai_chat_error: 'Noe gikk galt.',
ai_chat_context_title: 'Basert på din kontekst',
ai_chat_context_description:
'Docs-assistenten bruker AI og din kontekst til å generere svar og utføre handlinger.',
'Assistenten bruker AI og din kontekst til å generere svar og utføre handlinger.',
ai_chat_context_pages_youve_read: 'Sider du har lest',
ai_chat_context_info_provided_by_the_site: 'Informasjon gitt av nettstedet',
ai_chat_context_previous_messages: 'Tidligere meldinger',
@@ -92,4 +94,13 @@ export const no: TranslationLanguage = {
ai_chat_tools_listed_pages: 'Bladde gjennom docs',
ai_chat_tools_read_page: 'Leste side ${1}',
ai_chat_tools_mcp_tool: 'Kallte ${1}',
ai_chat_ask: 'Spør ${1}',
ai_chat_ask_about_page: 'Spør ${1} om denne siden',
copy_for_llms: 'Kopier for LLMs',
copy_page_markdown: 'Kopier siden som Markdown for LLMs',
copy_page: 'Kopier side',
view_page_markdown: 'Vis som Markdown',
view_page_plaintext: 'Vis denne siden som ren tekst',
open_in: 'Åpne i ${1}',
open_in_llms_pre_prompt: 'Les ${1} og besvar spørsmål om innholdet.',
};
@@ -14,6 +14,7 @@ export const pt_br = {
search_results_count: '${1} resultados',
search_scope_space: '${1}',
search_scope_all: 'Todo o conteúdo',
ask: 'Perguntar',
search_ask: 'Perguntar "${1}"',
search_ask_description: 'Encontre a resposta com IA',
search_ask_sources: 'Fontes',
@@ -64,7 +65,8 @@ export const pt_br = {
link_tooltip_external_link: 'Link externo para',
link_tooltip_page_anchor: 'Pular para a seção',
open_in_new_tab: 'Abrir em uma nova guia',
ai_chat_assistant_name: 'Assistente de Docs',
ai_chat_assistant_name: 'Assistente do GitBook',
ai_chat_assistant_name_unbranded: 'Assistente de IA',
ai_chat_assistant_description: 'Estou aqui para ajudá-lo com a documentação.',
ai_chat_assistant_greeting_morning: 'Bom dia',
ai_chat_assistant_greeting_afternoon: 'Boa tarde',
@@ -77,7 +79,7 @@ export const pt_br = {
ai_chat_error: 'Algo deu errado.',
ai_chat_context_title: 'Baseado no seu contexto',
ai_chat_context_description:
'O Assistente de Docs usa IA e seu contexto para gerar respostas e realizar ações.',
'O assistente usa IA e seu contexto para gerar respostas e realizar ações.',
ai_chat_context_pages_youve_read: 'Páginas que você leu',
ai_chat_context_info_provided_by_the_site: 'Informações fornecidas pelo site',
ai_chat_context_previous_messages: 'Mensagens anteriores',
@@ -92,4 +94,13 @@ export const pt_br = {
ai_chat_tools_listed_pages: 'Navegou pelos docs',
ai_chat_tools_read_page: 'Leu a página ${1}',
ai_chat_tools_mcp_tool: 'Chamou ${1}',
ai_chat_ask: 'Perguntar a ${1}',
ai_chat_ask_about_page: 'Perguntar a ${1} sobre esta página',
copy_for_llms: 'Copiar para LLMs',
copy_page_markdown: 'Copiar página como Markdown para LLMs',
copy_page: 'Copiar página',
view_page_markdown: 'Ver como Markdown',
view_page_plaintext: 'Ver esta página em texto simples',
open_in: 'Abrir no ${1}',
open_in_llms_pre_prompt: 'Leia ${1} e responda perguntas sobre o conteúdo.',
};
+13 -2
View File
@@ -16,6 +16,7 @@ export const zh: TranslationLanguage = {
search_results_count: '${1} 个结果',
search_scope_space: '${1}',
search_scope_all: '所有内容',
ask: '询问',
search_ask: '询问"${1}"',
search_ask_description: '利用人工智能找到答案',
search_ask_sources: '来源',
@@ -62,7 +63,8 @@ export const zh: TranslationLanguage = {
link_tooltip_external_link: '外部链接到',
link_tooltip_page_anchor: '跳转到页面',
open_in_new_tab: '在新标签页中打开',
ai_chat_assistant_name: '文档助手',
ai_chat_assistant_name: 'GitBook 助手',
ai_chat_assistant_name_unbranded: 'AI 助手',
ai_chat_assistant_description: '我在这里帮助您了解文档。',
ai_chat_assistant_greeting_morning: '早上好',
ai_chat_assistant_greeting_afternoon: '下午好',
@@ -74,7 +76,7 @@ export const zh: TranslationLanguage = {
ai_chat_context_badge: 'AI',
ai_chat_error: '出了些问题。',
ai_chat_context_title: '基于您的上下文',
ai_chat_context_description: '文档助手使用人工智能和您的上下文来生成答案并执行操作。',
ai_chat_context_description: '助手使用人工智能和您的上下文来生成答案并执行操作。',
ai_chat_context_pages_youve_read: '您已阅读的页面',
ai_chat_context_info_provided_by_the_site: '网站提供的信息',
ai_chat_context_previous_messages: '之前的消息',
@@ -89,4 +91,13 @@ export const zh: TranslationLanguage = {
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: '为 LLMs 复制',
copy_page_markdown: '将页面以 Markdown 格式复制供 LLMs 使用',
copy_page: '复制页面',
view_page_markdown: '以 Markdown 查看',
view_page_plaintext: '以纯文本查看此页面',
open_in: '在 ${1} 中打开',
open_in_llms_pre_prompt: '阅读 ${1} 并回答内容相关的问题。',
};
+7 -2
View File
@@ -4,6 +4,7 @@ import {
getDataOrNull,
throwIfDataError,
} from '@/lib/data';
import { getLogger } from '@/lib/logger';
import { getSiteStructureSections } from '@/lib/sites';
import type {
ChangeRequest,
@@ -24,7 +25,7 @@ import { notFound } from 'next/navigation';
import { assert } from 'ts-essentials';
import { GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images';
import { type GitBookLinker, createLinker } from './links';
import { type GitBookLinker, createLinker, linkerForPublishedURL } from './links';
/**
* Data about the site URL. Provided by the middleware.
@@ -290,10 +291,11 @@ export async function fetchSiteContextByIds(
return siteSpaceSettings;
}
const logger = getLogger().subLogger('fetchSiteContextByIds', {});
// We got the pointer from an API and customizations from another.
// It's possible that the two are unsynced leading to not found customizations for the space.
// It's better to fallback on customization of the site that displaying an error.
console.warn('Customization not found for site space', ids.siteSpace);
logger.warn('Customization not found for site space', ids.siteSpace);
}
return customizations.site;
@@ -301,6 +303,9 @@ export async function fetchSiteContextByIds(
return {
...spaceContext,
linker: site.urls.published
? linkerForPublishedURL(spaceContext.linker, site.urls.published)
: spaceContext.linker,
organizationId: ids.organization,
site,
siteSpaces,
+103 -91
View File
@@ -7,6 +7,7 @@ import {
type RenderIntegrationUI,
} from '@gitbook/api';
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
import { parse as parseCacheControl } from '@tusbar/cache-control';
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import { cache } from '../cache';
import { DataFetcherError, wrapCacheDataFetcherError } from './errors';
@@ -50,128 +51,139 @@ export function createDataFetcher(
// API that are tied to the token
//
getPublishedContentSite(params) {
return trace('getPublishedContentSite', () =>
getPublishedContentSite(input, {
organizationId: params.organizationId,
siteId: params.siteId,
siteShareKey: params.siteShareKey,
})
);
return getPublishedContentSite(input, {
organizationId: params.organizationId,
siteId: params.siteId,
siteShareKey: params.siteShareKey,
});
},
getSiteRedirectBySource(params) {
return trace('getSiteRedirectBySource', () =>
getSiteRedirectBySource(input, {
organizationId: params.organizationId,
siteId: params.siteId,
siteShareKey: params.siteShareKey,
source: params.source,
})
);
return getSiteRedirectBySource(input, {
organizationId: params.organizationId,
siteId: params.siteId,
siteShareKey: params.siteShareKey,
source: params.source,
});
},
getRevision(params) {
return trace('getRevision', () =>
getRevision(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
})
);
return getRevision(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
});
},
getRevisionPageByPath(params) {
return trace('getRevisionPageByPath', () =>
getRevisionPageByPath(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
path: params.path,
})
);
return getRevisionPageByPath(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
path: params.path,
});
},
getRevisionPageMarkdown(params) {
return trace('getRevisionPageMarkdown', () =>
getRevisionPageMarkdown(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
pageId: params.pageId,
})
);
return getRevisionPageMarkdown(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
pageId: params.pageId,
});
},
getRevisionPageDocument(params) {
return trace('getRevisionPageDocument', () =>
getRevisionPageDocument(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
pageId: params.pageId,
})
);
return getRevisionPageDocument(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
pageId: params.pageId,
});
},
getLatestOpenAPISpecVersionContent(params) {
return trace('getLatestOpenAPISpecVersionContent', () =>
getLatestOpenAPISpecVersionContent(input, {
organizationId: params.organizationId,
slug: params.slug,
})
);
return getLatestOpenAPISpecVersionContent(input, {
organizationId: params.organizationId,
slug: params.slug,
});
},
getSpace(params) {
return trace('getSpace', () =>
getSpace(input, {
spaceId: params.spaceId,
shareKey: params.shareKey,
})
);
return getSpace(input, {
spaceId: params.spaceId,
shareKey: params.shareKey,
});
},
getChangeRequest(params) {
return trace('getChangeRequest', () =>
getChangeRequest(input, {
spaceId: params.spaceId,
changeRequestId: params.changeRequestId,
})
);
return getChangeRequest(input, {
spaceId: params.spaceId,
changeRequestId: params.changeRequestId,
});
},
getDocument(params) {
return trace('getDocument', () =>
getDocument(input, {
spaceId: params.spaceId,
documentId: params.documentId,
})
);
return getDocument(input, {
spaceId: params.spaceId,
documentId: params.documentId,
});
},
getComputedDocument(params) {
return trace('getComputedDocument', () =>
getComputedDocument(input, {
organizationId: params.organizationId,
spaceId: params.spaceId,
source: params.source,
seed: params.seed,
})
);
return getComputedDocument(input, {
organizationId: params.organizationId,
spaceId: params.spaceId,
source: params.source,
seed: params.seed,
});
},
getEmbedByUrl(params) {
return trace('getEmbedByUrl', () =>
getEmbedByUrl(input, {
url: params.url,
spaceId: params.spaceId,
})
);
return getEmbedByUrl(input, {
url: params.url,
spaceId: params.spaceId,
});
},
searchSiteContent(params) {
return trace('searchSiteContent', () => searchSiteContent(input, params));
return searchSiteContent(input, params);
},
renderIntegrationUi(params) {
return trace('renderIntegrationUi', () =>
renderIntegrationUi(input, {
integrationName: params.integrationName,
request: params.request,
})
);
return renderIntegrationUi(input, {
integrationName: params.integrationName,
request: params.request,
});
},
getUserById(userId) {
return trace('getUserById', () => getUserById(input, { userId }));
return getUserById(input, { userId });
},
};
}
/**
* Infer the cache life from the api response headers.
* @param response The response from the API call.
* @param defaultCacheLife The default cache life to use if not specified in the response.
* @returns nothing
*/
function cacheLifeFromResponse(
response: HttpResponse<unknown, unknown>,
defaultCacheLife: 'days' | 'max' | 'hours' | 'minutes' | 'seconds'
) {
const cacheControlHeader = response.headers.get('x-gitbook-cache-control');
const parsed = parseCacheControl(cacheControlHeader || '');
const maxAge = parsed?.maxAge ?? parsed?.sharedMaxAge;
if (maxAge) {
return cacheLife({
stale: 60 * 5, // This one is only for the client,
revalidate: maxAge, // revalidate and expire are the same, we don't want stale data here
expire: maxAge,
});
}
// Typings in Next is "wrong" and does not allow us just use it as `cacheLife(defaultCacheLife)`
switch (defaultCacheLife) {
case 'days':
return cacheLife('days');
case 'max':
return cacheLife('max');
case 'hours':
return cacheLife('hours');
case 'minutes':
return cacheLife('minutes');
case 'seconds':
return cacheLife('seconds');
default:
throw new Error(`Unknown default cache life: ${defaultCacheLife}`);
}
}
const getUserById = cache(async (input: DataFetcherInput, params: { userId: string }) => {
'use cache';
return wrapCacheDataFetcherError(async () => {
@@ -333,7 +345,7 @@ const getRevisionPageDocument = cache(
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
cacheLifeFromResponse(res, 'max');
return res.data;
}
@@ -387,7 +399,7 @@ const getDocument = cache(
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
cacheLifeFromResponse(res, 'max');
return res.data;
});
});
@@ -432,7 +444,7 @@ const getComputedDocument = cache(
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
cacheLifeFromResponse(res, 'max');
return res.data;
}
);
+3 -9
View File
@@ -1,9 +1,8 @@
import { GitBookAPIError } from '@gitbook/api';
import { parse as parseCacheControl } from '@tusbar/cache-control';
import { unstable_cacheLife as cacheLife } from 'next/cache';
import type { DataFetcherErrorData, DataFetcherResponse } from './types';
import parseCacheControl from 'parse-cache-control';
export class DataFetcherError extends Error {
constructor(
message: string,
@@ -164,13 +163,8 @@ export function extractCacheControl(error: GitBookAPIError) {
}
const parsed = parseCacheControl(cacheControl);
//parseCacheControl does not support stale-while-revalidate, so we need to parse it manually
const staleWhileRevalidateMatch = cacheControl.match(/stale-while-revalidate=(\d+)/i);
const maxAge = parsed?.['max-age'];
const staleWhileRevalidate = staleWhileRevalidateMatch
? Number.parseInt(staleWhileRevalidateMatch[1], 10)
: undefined;
const maxAge = parsed?.maxAge ?? parsed?.sharedMaxAge ?? 0;
const staleWhileRevalidate = parsed.staleWhileRevalidate ?? undefined;
return {
// If maxAge is 0, we want to apply the default, not 0
@@ -1,4 +1,5 @@
import { GITBOOK_IMAGE_RESIZE_URL } from '@/lib/env';
import { getLogger } from '@/lib/logger';
import type { CloudflareImageOptions } from './types';
import { copyImageResponse } from './utils';
@@ -24,8 +25,8 @@ export async function resizeImageWithCDNCgi(
resizeOptions
)}/${encodeURIComponent(input)}`;
// biome-ignore lint/suspicious/noConsole: this log is useful for debugging
console.log(`resize image using cdn-cgi: ${resizeURL}`);
const logger = getLogger().subLogger('imageResizing');
logger.log(`resize image using cdn-cgi: ${resizeURL}`);
return copyImageResponse(
await fetch(resizeURL, {
@@ -1,3 +1,4 @@
import { getLogger } from '@/lib/logger';
import type { CloudflareImageOptions } from './types';
import { copyImageResponse } from './utils';
@@ -13,8 +14,8 @@ export async function resizeImageWithCFFetch(
): Promise<Response> {
const { signal, ...resizeOptions } = options;
// biome-ignore lint/suspicious/noConsole: this log is useful for debugging
console.log(`resize image using cf-fetch: ${input}`);
const logger = getLogger().subLogger('imageResizing');
logger.log(`resize image using cf-fetch: ${input}`);
return copyImageResponse(
await fetch(input, {
@@ -1,4 +1,5 @@
import 'server-only';
import { getLogger } from '@/lib/logger';
import assertNever from 'assert-never';
import { GITBOOK_IMAGE_RESIZE_MODE } from '../../env';
import { SizableImageAction, checkIsSizableImageURL } from '../checkIsSizableImageURL';
@@ -34,7 +35,8 @@ export async function getImageSize(
height: json.original.height,
};
} catch (error) {
console.warn(`Error getting image size for ${input}:`, error);
const logger = getLogger().subLogger('imageResizing');
logger.warn(`Error getting image size for ${input}:`, error);
return null;
}
}
@@ -1,5 +1,6 @@
import 'server-only';
import { getLogger } from '@/lib/logger';
import fnv1a from '@sindresorhus/fnv1a';
import type { MaybePromise } from 'p-map';
import { assert } from 'ts-essentials';
@@ -33,10 +34,13 @@ export async function verifyImageSignature(
const generator = IMAGE_SIGNATURE_FUNCTIONS[version];
const generated = await generator(input);
// biome-ignore lint/suspicious/noConsole: we want to log the signature comparison
console.log(
`comparing image signature for "${input.url}" on identifier "${input.imagesContextId}": "${generated}" (expected) === "${signature}" (actual)`
);
const logger = getLogger().subLogger('imageResizing');
if (generated !== signature) {
// We only log if the signature does not match, to avoid logging useless information
logger.log(
`comparing image signature for "${input.url}" on identifier "${input.imagesContextId}": "${generated}" (expected) === "${signature}" (actual)`
);
}
return generated === signature;
}
+62 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'bun:test';
import { createLinker, linkerWithAbsoluteURLs, linkerWithOtherSpaceBasePath } from './links';
import {
createLinker,
linkerForPublishedURL,
linkerWithAbsoluteURLs,
linkerWithOtherSpaceBasePath,
} from './links';
const root = createLinker({
host: 'docs.company.com',
@@ -19,6 +24,12 @@ const siteGitBookIO = createLinker({
siteBasePath: '/sitename/',
});
const preview = createLinker({
host: 'preview',
spaceBasePath: '/site_abc/section/space/',
siteBasePath: '/site_abc/',
});
describe('toPathInSpace', () => {
it('should return the correct path', () => {
expect(root.toPathInSpace('some/path')).toBe('/some/path');
@@ -88,6 +99,12 @@ describe('toLinkForContent', () => {
);
});
it('should preserve the search and hash', () => {
expect(root.toLinkForContent('https://docs.company.com/some/path?a=b#c')).toBe(
'/some/path?a=b#c'
);
});
it('should preserve an absolute URL if the site is not the same', () => {
expect(siteGitBookIO.toLinkForContent('https://org.gitbook.io/anothersite/some/path')).toBe(
'https://org.gitbook.io/anothersite/some/path'
@@ -138,3 +155,47 @@ describe('linkerWithOtherSpaceBasePath', () => {
expect(otherSpaceBasePathLinker.toPathInSpace('some/path')).toBe('/sitename/a/b/some/path');
});
});
describe('linkerForPublishedURL', () => {
describe('Root custom domain', () => {
it('should rewrite links that belongs to the published site to be part of the preview site', () => {
const previewLinker = linkerForPublishedURL(preview, 'https://docs.company.com/');
expect(previewLinker.toLinkForContent('https://docs.company.com/some/path')).toBe(
'/site_abc/some/path'
);
expect(
previewLinker.toLinkForContent('https://docs.company.com/section/variant/some/path')
).toBe('/site_abc/section/variant/some/path');
expect(previewLinker.toLinkForContent('https://www.google.com')).toBe(
'https://www.google.com'
);
});
});
describe('gitbook.io domain', () => {
it('should rewrite links that belongs to the published site to be part of the preview site', () => {
const previewLinker = linkerForPublishedURL(
preview,
'https://org.gitbook.io/sitename/'
);
expect(
previewLinker.toLinkForContent('https://org.gitbook.io/sitename/some/path')
).toBe('/site_abc/some/path');
expect(
previewLinker.toLinkForContent(
'https://org.gitbook.io/sitename/section/variant/some/path'
)
).toBe('/site_abc/section/variant/some/path');
expect(previewLinker.toLinkForContent('https://www.google.com')).toBe(
'https://www.google.com'
);
});
});
it('should should preserve hash and search', () => {
const previewLinker = linkerForPublishedURL(preview, 'https://docs.company.com/');
expect(previewLinker.toLinkForContent('https://docs.company.com/some/path?a=b#c')).toBe(
'/site_abc/some/path?a=b#c'
);
});
});
+32 -1
View File
@@ -115,7 +115,7 @@ export function createLinker(
// If the link points to a content in the same site, we return an absolute path
// instead of a full URL; it makes it possible to use router navigation
if (url.hostname === servedOn.host && url.pathname.startsWith(servedOn.siteBasePath)) {
return url.pathname;
return url.pathname + url.search + url.hash;
}
return rawURL;
@@ -125,6 +125,37 @@ export function createLinker(
return linker;
}
/**
* Create a new linker that intercepts links that belongs to the published site and rewrite them
* relative to the URL being served.
*
* It is needed for preview of site where the served URL (http://preview/site_abc)
* is different from the actual published site URL (https://docs.company.com).
*/
export function linkerForPublishedURL(linker: GitBookLinker, rawSitePublishedURL: string) {
const sitePublishedURL = new URL(rawSitePublishedURL);
return {
...linker,
toLinkForContent(rawURL: string): string {
const url = new URL(rawURL);
// If the link is part of the published site, we rewrite it to be part of the preview site.
if (
url.hostname === sitePublishedURL.hostname &&
url.pathname.startsWith(sitePublishedURL.pathname)
) {
// When detecting that the url has been computed as apart of the published site,
// we rewrite it to be part of the preview site.
const extractedPath = url.pathname.slice(sitePublishedURL.pathname.length);
return linker.toPathInSite(extractedPath) + url.search + url.hash;
}
return linker.toLinkForContent(rawURL);
},
};
}
/**
* Create a new linker that always returns absolute URLs.
*/
+61
View File
@@ -0,0 +1,61 @@
export type LogLabelValue = string | number | undefined | boolean;
export type LogLabels = { [key: string]: LogLabelValue };
export type SubLoggerOptions = {
labels?: LogLabels;
};
export type LoggerOptions = SubLoggerOptions & {
requestId?: string;
};
const formatPrefix = (name: string, options: LoggerOptions) => {
const { labels } = options;
// The tail worker used for extracting the labels expect this format.
return `%${JSON.stringify(labels ?? {})}%[${name}]`;
};
/**
* Creates a logger with the given name, the logger will prefix all log messages with the name in square brackets.
* By default it will include a `requestId` label, and output labels in the log messages.
* Format of log messages will be:
* `%{"label1":"value1","label2":"value2"}%[loggerName] message...`
* @param name The name of the logger, which can be used to create a sub-logger.
* @param requestId An optional request ID to include in log messages for better traceability.
* @returns A logger object with methods for logging at different levels and creating sub-loggers.
*/
export const createLogger = (name: string, options: LoggerOptions) => {
const { requestId, labels } = options;
const finalOptions = { requestId, labels: { ...labels, requestId } };
return {
subLogger: (subName: string, options?: SubLoggerOptions) =>
createLogger(`${name}:${subName}`, {
requestId,
labels: { ...labels, ...(options?.labels ?? {}) },
}),
info: (...data: any[]) => console.info(formatPrefix(name, finalOptions), ...data),
warn: (...data: any[]) => console.warn(formatPrefix(name, finalOptions), ...data),
error: (...data: any[]) => console.error(formatPrefix(name, finalOptions), ...data),
debug: (...data: any[]) => console.debug(formatPrefix(name, finalOptions), ...data),
log: (...data: any[]) => console.log(formatPrefix(name, finalOptions), ...data),
};
};
export type GitbookLogger = ReturnType<typeof createLogger>;
const getRequestId = (fallbackRequestId?: string) => {
// We are in OpenNext and we should have access to the request ID.
if ((globalThis as any).__openNextAls) {
return (globalThis as any).__openNextAls.getStore()?.requestId;
}
return fallbackRequestId;
};
export const getLogger =
process.env.NEXT_RUNTIME === 'edge'
? () => createLogger('GBOV2', { requestId: getRequestId() })
: () =>
createLogger('GBOV2', {
requestId: getRequestId(Math.random().toString(36).substring(2, 15)),
});
+168
View File
@@ -0,0 +1,168 @@
import type { GitBookSiteContext } from '@/lib/context';
import type { DataFetcherResponse } from '@/lib/data';
import { resolvePagePathDocumentOrGroup } from '@/lib/pages';
import { getIndexablePages } from '@/lib/sitemap';
import { getMarkdownForPagesTree } from '@/routes/llms';
import { type RevisionPageDocument, type RevisionPageGroup, RevisionPageType } from '@gitbook/api';
import type { Root } from 'mdast';
import { toMarkdown } from 'mdast-util-to-markdown';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
type MarkdownResult = DataFetcherResponse<string>;
/**
* Generate a markdown version of a page.
* Handles both regular document pages and group pages (pages with child pages).
*/
export async function getMarkdownForPage(
context: GitBookSiteContext,
pagePath: string
): Promise<MarkdownResult> {
const pageLookup = resolvePagePathDocumentOrGroup(context.revision.pages, pagePath);
if (!pageLookup) {
return {
error: {
message: `Page "${pagePath}" not found`,
code: 404,
},
};
}
const { page } = pageLookup;
// Only handle documents and groups
if (page.type !== RevisionPageType.Document && page.type !== RevisionPageType.Group) {
return {
error: {
message: `Page "${pagePath}" is not a document or group`,
code: 400,
},
};
}
// Handle group pages
if (page.type === RevisionPageType.Group) {
return servePageGroup(context, page);
}
const { data: markdown, error } = await context.dataFetcher.getRevisionPageMarkdown({
spaceId: context.space.id,
revisionId: context.revisionId,
pageId: page.id,
});
if (error) {
return {
error: {
message: 'An error occurred while fetching the markdown for this page',
code: 500,
},
};
}
// Handle empty document pages which have children
if (isEmptyMarkdownPage(markdown) && page.pages.length > 0) {
return servePageGroup(context, page);
}
return {
data: markdown,
};
}
/**
* Determine if a page is empty.
* A page is empty if it has no content or only a title.
*/
function isEmptyMarkdownPage(markdown: string): boolean {
// Remove frontmatter
const stripped = markdown
.trim()
.replace(/^---\n[\s\S]*?\n---\n?/g, '')
.trim();
// Fast path: try to quickly detect obvious matches
if (/^[ \t]*# .+$/m.test(stripped)) {
// If there's a single heading line or empty lines, and no other content
return (
/^#{1,6} .+\s*$/.test(stripped) &&
!/\n\S+/g.test(stripped.split('\n').slice(1).join('\n'))
);
}
// Fallback: parse with remark for safety
const tree = unified().use(remarkParse).parse(stripped) as Root;
let seenHeading = false;
for (const node of tree.children) {
if (node.type === 'heading') {
if (seenHeading) {
return false;
}
seenHeading = true;
continue;
}
// Allow empty whitespace-only text nodes (e.g., extra newlines)
if (
node.type === 'paragraph' &&
node.children.length === 1 &&
node.children[0].type === 'text' &&
!node.children[0].value.trim()
) {
continue;
}
// Anything else is disallowed
return false;
}
return seenHeading;
}
/**
* Generate markdown for a group page by creating a page listing.
* Creates a markdown document with the page title as heading and a list of child pages.
*/
async function servePageGroup(
context: GitBookSiteContext,
page: RevisionPageDocument | RevisionPageGroup
): Promise<MarkdownResult> {
const siteSpaceUrl = context.space.urls.published;
if (!siteSpaceUrl) {
return {
error: {
message: `Page "${page.title}" is not published`,
code: 404,
},
};
}
const indexablePages = getIndexablePages(page.pages);
// Create a markdown tree with the page title as heading and a list of child pages
const markdownTree: Root = {
type: 'root',
children: [
{
type: 'heading',
depth: 1,
children: [{ type: 'text', value: page.title }],
},
...(await getMarkdownForPagesTree(indexablePages, {
siteSpaceUrl,
linker: context.linker,
withMarkdownPages: true,
})),
],
};
return {
data: toMarkdown(markdownTree, {
bullet: '-',
}),
};
}
+28 -6
View File
@@ -1,6 +1,11 @@
import { OpenAPIParseError, parseOpenAPI } from '@gitbook/openapi-parser';
import {
type Filesystem,
OpenAPIParseError,
type OpenAPIParseErrorCode,
parseOpenAPI,
} from '@gitbook/openapi-parser';
import { noCacheFetchOptions } from '@/lib/data';
import { DataFetcherError, noCacheFetchOptions } from '@/lib/data';
import { resolveContentRef } from '@/lib/references';
import { unstable_cacheLife as cacheLife } from 'next/cache';
import { assert } from 'ts-essentials';
@@ -48,18 +53,35 @@ export async function fetchOpenAPIFilesystem(
};
}
const fetchFilesystem = async (url: string) => {
const fetchFilesystem = async (
url: string
): Promise<
| Filesystem
| {
error: {
code: OpenAPIParseErrorCode;
message: string;
};
}
> => {
'use cache';
try {
return await fetchFilesystemUncached(url);
} catch (error) {
// To avoid hammering the file with requests, we cache the error for around a minute.
cacheLife('minutes');
// Throwing an error inside a "use cache" function obfuscates the error,
// so we need to handle it here and recreates the error outside the cache function.
if (error instanceof OpenAPIParseError) {
cacheLife('seconds');
return { error: { code: error.code, message: error.message } };
}
throw error;
if (error instanceof DataFetcherError) {
return { error: { code: 'invalid' as const, message: 'Failed to fetch OpenAPI file' } };
}
// If the error is not an OpenAPIParseError or DataFetcherError,
// we assume it's an unknown error and return a generic error.
console.error('Unknown error while fetching OpenAPI file:', error);
return { error: { code: 'invalid' as const, message: 'Unknown error' } };
}
};
@@ -78,7 +100,7 @@ async function fetchFilesystemUncached(
});
if (!response.ok) {
throw new Error(`Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`);
throw new DataFetcherError('Failed to fetch OpenAPI file', response.status);
}
const text = await response.text();
+4 -2
View File
@@ -1,3 +1,5 @@
import { getLogger } from '@/lib/logger';
export interface TraceSpan {
setAttribute: (label: string, value: boolean | string | number) => void;
}
@@ -38,8 +40,8 @@ export async function trace<T>(
} finally {
if (process.env.SILENT !== 'true' && process.env.NODE_ENV !== 'development') {
const end = now();
// biome-ignore lint/suspicious/noConsole: we want to log performance data
console.log(
const logger = getLogger().subLogger(operation);
logger.log(
`trace ${completeName} ${traceError ? `failed with ${traceError.message}` : 'succeeded'} in ${end - start}ms`,
attributes
);
+24 -137
View File
@@ -1,148 +1,35 @@
import type { GitBookSiteContext } from '@/lib/context';
import { throwIfDataError } from '@/lib/data';
import { resolvePagePathDocumentOrGroup } from '@/lib/pages';
import { getIndexablePages } from '@/lib/sitemap';
import { getMarkdownForPagesTree } from '@/routes/llms';
import { type RevisionPageDocument, type RevisionPageGroup, RevisionPageType } from '@gitbook/api';
import type { Root } from 'mdast';
import { toMarkdown } from 'mdast-util-to-markdown';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
import { getDataOrNull } from '@/lib/data';
import { getMarkdownForPage } from '@/lib/markdownPage';
/**
* Generate a markdown version of a page.
* Handles both regular document pages and group pages (pages with child pages).
* Serve a markdown version of a page.
* Returns a 404 if the page is not found.
*/
export async function servePageMarkdown(context: GitBookSiteContext, pagePath: string) {
const pageLookup = resolvePagePathDocumentOrGroup(context.revision.pages, pagePath);
if (!pageLookup) {
return new Response(`Page "${pagePath}" not found`, { status: 404 });
}
const { page } = pageLookup;
// Only handle documents and groups
if (page.type !== RevisionPageType.Document && page.type !== RevisionPageType.Group) {
return new Response(`Page "${pagePath}" is not a document or group`, { status: 404 });
}
// Handle group pages
if (page.type === RevisionPageType.Group) {
return servePageGroup(context, page);
}
const markdown = await throwIfDataError(
context.dataFetcher.getRevisionPageMarkdown({
spaceId: context.space.id,
revisionId: context.revision.id,
pageId: page.id,
})
);
// Handle empty document pages which have children
if (isEmptyMarkdownPage(markdown) && page.pages.length > 0) {
return servePageGroup(context, page);
}
return new Response(markdown, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
},
});
}
/**
* Determine if a page is empty.
* A page is empty if it has no content or only a title.
*/
function isEmptyMarkdownPage(markdown: string): boolean {
// Remove frontmatter
const stripped = markdown
.trim()
.replace(/^---\n[\s\S]*?\n---\n?/g, '')
.trim();
// Fast path: try to quickly detect obvious matches
if (/^[ \t]*# .+$/m.test(stripped)) {
// If there's a single heading line or empty lines, and no other content
return (
/^#{1,6} .+\s*$/.test(stripped) &&
!/\n\S+/g.test(stripped.split('\n').slice(1).join('\n'))
);
}
// Fallback: parse with remark for safety
const tree = unified().use(remarkParse).parse(stripped) as Root;
let seenHeading = false;
for (const node of tree.children) {
if (node.type === 'heading') {
if (seenHeading) {
return false;
}
seenHeading = true;
continue;
try {
const result = await getDataOrNull(getMarkdownForPage(context, pagePath));
if (!result) {
return new Response('Page not found', {
status: 404,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
});
}
// Allow empty whitespace-only text nodes (e.g., extra newlines)
if (
node.type === 'paragraph' &&
node.children.length === 1 &&
node.children[0].type === 'text' &&
!node.children[0].value.trim()
) {
continue;
}
// Anything else is disallowed
return false;
}
return seenHeading;
}
/**
* Generate markdown for a group page by creating a page listing.
* Creates a markdown document with the page title as heading and a list of child pages.
*/
async function servePageGroup(
context: GitBookSiteContext,
page: RevisionPageDocument | RevisionPageGroup
) {
const siteSpaceUrl = context.space.urls.published;
if (!siteSpaceUrl) {
return new Response(`Page "${page.title}" is not published`, { status: 404 });
}
const indexablePages = getIndexablePages(page.pages);
// Create a markdown tree with the page title as heading and a list of child pages
const markdownTree: Root = {
type: 'root',
children: [
{
type: 'heading',
depth: 1,
children: [{ type: 'text', value: page.title }],
},
...(await getMarkdownForPagesTree(indexablePages, {
siteSpaceUrl,
linker: context.linker,
withMarkdownPages: true,
})),
],
};
return new Response(
toMarkdown(markdownTree, {
bullet: '-',
}),
{
return new Response(result, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
},
}
);
});
} catch (error) {
console.error('Error serving markdown page:', error);
return new Response('Internal Server Error', {
status: 500,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
});
}
}
+32 -7
View File
@@ -375,20 +375,36 @@ const config: Config = {
},
},
pathLoading: {
'0%,10%': {
strokeDasharray: '100 100',
strokeDashoffset: '0',
opacity: '1',
},
'50%': {
strokeDasharray: '100 100',
strokeDashoffset: '-100',
opacity: '0',
},
'51%': {
strokeDasharray: '0 100',
strokeDashoffset: '0',
opacity: '0',
},
'90%,100%': {
strokeDasharray: '100 100',
strokeDashoffset: '0',
opacity: '1',
},
},
pathEnter: {
'0%': {
strokeDasharray: '0 100',
strokeDashoffset: '0',
opacity: '0',
},
'40%, 60%': {
strokeDasharray: '100 100',
strokeDashoffset: '0',
opacity: '1',
},
'100%': {
strokeDasharray: '100 100',
strokeDashoffset: '-100',
opacity: '0',
strokeDashoffset: '0',
},
},
stroke: {
@@ -408,6 +424,14 @@ const config: Config = {
opacity: '0',
},
},
bob: {
'0%, 100%': {
transform: 'translateY(0)',
},
'50%': {
transform: 'translateY(-10%)',
},
},
enterFromRight: {
from: { opacity: '0', transform: 'translateX(50%)', display: 'none' },
to: { opacity: '1', transform: 'translateX(0)', display: 'block' },
@@ -450,6 +474,7 @@ const config: Config = {
},
opacity: opacity(),
screens: {
xs: '480px',
sm: '640px',
md: '768px',
lg: '1024px',
+11 -5
View File
@@ -1,19 +1,25 @@
# @gitbook/icons
## 0.2.1
### Patch Changes
- 6816f0f: add browserlists and fix old browser css-masks
## 0.2.0
### Minor Changes
- 88f64b0: Fix the build to support Node.js v20
- 88f64b0: Fix the build to support Node.js v20
## 0.1.0
### Minor Changes
- 5c35f36: Initial release for `@gitbook/icons`, a package of content icons to be used for pages, documents, etc.
- 776bc31: Fix bin gitbook-icons when installed without the Pro token
- 5c35f36: Initial release for `@gitbook/icons`, a package of content icons to be used for pages, documents, etc.
- 776bc31: Fix bin gitbook-icons when installed without the Pro token
### Patch Changes
- d0f4860: Update version for static assets to fix invalid caching
- ef9d012: Fix first run of the package in development mode
- d0f4860: Update version for static assets to fix invalid caching
- ef9d012: Fix first run of the package in development mode
+1 -1
View File
@@ -16,7 +16,7 @@
"default": "./src/style.css"
}
},
"version": "0.2.0",
"version": "0.2.1",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/fontawesome-free": "^6.6.0"
+4
View File
@@ -58,8 +58,12 @@ export const Icon = React.forwardRef(function Icon(
{...rest}
style={{
maskImage: `url(${url})`,
//@ts-ignore // For compatibility with older browsers
'-webkit-mask-image': `url(${url})`,
maskRepeat: 'no-repeat',
'-webkit-mask-repeat': 'no-repeat',
maskPosition: 'center',
'-webkit-mask-position': 'center',
...(size ? { width: size, height: size } : {}),
...rest.style,
}}
+12
View File
@@ -1,5 +1,17 @@
# @gitbook/openapi-parser
## 2.2.1
### Patch Changes
- 1738677: Stricter validation
## 2.2.0
### Minor Changes
- bd553bc: Expose `isJson` and `isYaml` in OpenAPI parser
## 2.1.5
### Patch Changes
+1 -1
View File
@@ -9,7 +9,7 @@
"default": "./dist/index.js"
}
},
"version": "2.1.5",
"version": "2.2.1",
"sideEffects": false,
"dependencies": {
"@scalar/openapi-parser": "^0.18.0",
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ErrorObject } from '@scalar/openapi-parser';
type OpenAPIParseErrorCode =
export type OpenAPIParseErrorCode =
| 'invalid'
| 'parse-v2-in-v3'
| 'v2-conversion'
+1 -1
View File
@@ -1,6 +1,6 @@
export { parseOpenAPI } from './parse';
export { dereference } from '@scalar/openapi-parser';
export { dereference, isJson, isYaml } from '@scalar/openapi-parser';
export type { AnyObject } from '@scalar/openapi-parser';
export type * from '@scalar/openapi-types';
export * from './error';
+18 -1
View File
@@ -1,8 +1,11 @@
import { describe, it } from 'bun:test';
import { describe, expect, it } from 'bun:test';
import { OpenAPIParseError } from './error';
import { parseOpenAPI } from './parse';
const spec = await Bun.file(new URL('./fixtures/recursive-spec.json', import.meta.url)).text();
const html = `<!DOCTYPE html><html lang=\"en\"><head> <meta charset=\"utf-8\"> <title>API Documentation</title> <base href=\"/api-docs/\"> <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"> <link rel=\"icon\" type=\"image/x-icon\" href=\"favicon.ico\"> <link rel=\"stylesheet\" href=\"https://use.fontawesome.com/releases/v5.7.0/css/all.css\" integrity=\"sha384-lZN37f5QGtY3VHgisS14W3ExzMWZxybE1SJSEsQp9S+oqd12jhcu+A56Ebc1zFSJ\" crossorigin=\"anonymous\"> <style>@import\"https://fonts.googleapis.com/css2?family=Nunito:wght@200&display=swap\";@charset \"UTF-8\";:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,\"Segoe UI\",Roboto,\"Helvetica Neue\",\"Noto Sans\",\"Liberation Sans\",Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\",\"Noto Color Emoji\";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, .15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, .175);--bs-border-radius:.375rem;--bs-border-radius-sm:.25rem;--bs-border-radius-lg:.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-2xl:2rem;--bs-border-radius-pill:50rem;--bs-link-color:#0d6efd;--bs-link-hover-color:#0a58ca;--bs-code-color:#d63384;--bs-highlight-bg:#fff3cd}*,:after,:before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}:root{--primaryColor1:#373e3e;--primaryColor2:#373e3e;--white:#ffffff;--lightGrey:#F8F9F9;--grey:#F8F9F9;--darkGrey:#6c757d;--black:black}*{box-sizing:border-box}body{font-family:Nunito,sans-serif;background-color:var(--grey);margin:5px auto}</style><link rel=\"stylesheet\" href=\"styles.css\" media=\"print\" onload=\"this.media='all'\"><noscript><link rel=\"stylesheet\" href=\"styles.css\"></noscript></head> <body> <app-root></app-root> <script> parent.postMessage(location.hash, location.origin); </script> <script src=\"runtime.js\" type=\"module\"></script><script src=\"polyfills.js\" type=\"module\"></script><script src=\"scripts.js\" defer></script><script src=\"main.js\" type=\"module\"></script> <script type=\"text/javascript\" src=\"/_Incapsula_Resource?SWJIYLWA=719d34d31c8e3a6e6fffd425f7e032f3&ns=2&cb=242625292\" async></script></body></html>`;
describe('#parseOpenAPI', () => {
it('parses an OpenAPI document', async () => {
const schema = await parseOpenAPI({
@@ -12,4 +15,18 @@ describe('#parseOpenAPI', () => {
// Ensure the structure returned is not recursive (not dereferenced).
JSON.stringify(schema);
});
it('throws an error for invalid OpenAPI document', async () => {
expect.assertions(1);
try {
await parseOpenAPI({
value: html,
rootURL: null,
});
} catch (error) {
if (error instanceof OpenAPIParseError) {
expect(error.message).toContain('Invalid OpenAPI document');
}
}
});
});
+1 -1
View File
@@ -36,7 +36,7 @@ async function untrustedValidate(input: ValidateOpenAPIV3Input) {
const result = await validate(value);
// Spec is invalid, we stop here.
if (!result.specification) {
if (!result.valid) {
throw new OpenAPIParseError('Invalid OpenAPI document', {
code: 'invalid',
rootURL,
+7
View File
@@ -1,5 +1,12 @@
# @gitbook/react-contentkit
## 0.7.2
### Patch Changes
- Updated dependencies [6816f0f]
- @gitbook/icons@0.2.1
## 0.7.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@gitbook/react-contentkit",
"version": "0.7.1",
"version": "0.7.2",
"exports": {
".": {
"types": "./dist/index.d.ts",
+14
View File
@@ -1,5 +1,19 @@
# @gitbook/react-openapi
## 1.3.3
### Patch Changes
- Updated dependencies [1738677]
- @gitbook/openapi-parser@2.2.1
## 1.3.2
### Patch Changes
- Updated dependencies [bd553bc]
- @gitbook/openapi-parser@2.2.0
## 1.3.1
### Patch Changes
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js"
}
},
"version": "1.3.1",
"version": "1.3.3",
"sideEffects": false,
"dependencies": {
"@gitbook/openapi-parser": "workspace:*",
@@ -40,8 +40,12 @@ export async function resolveOpenAPIOperation(
}
const servers = 'servers' in schema ? (schema.servers ?? []) : [];
const security: OpenAPIV3_1.SecurityRequirementObject[] =
operation.security ?? schema.security ?? [];
const schemaSecurity = Array.isArray(schema.security)
? schema.security
: schema.security
? [schema.security]
: [];
const security: OpenAPIV3_1.SecurityRequirementObject[] = operation.security ?? schemaSecurity;
// If security includes an empty object, it means that the security is optional
const isOptionalSecurity = security.some((entry) => Object.keys(entry).length === 0);