Compare commits

..

55 Commits

Author SHA1 Message Date
Samy Pessé 295a7fab34 Changeset 2025-09-14 18:04:52 +02:00
Samy Pessé 3a23005020 Fix it 2025-09-14 12:58:12 +02:00
Samy Pessé 0b64e40d9f Expose hook useVisitorSession 2025-09-14 12:39:32 +02:00
Samy Pessé 229f2ba9d5 Version Packages (#3646)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 16:40:28 +02:00
spastorelli 6da36558f6 Fix exports in gitbook/expr package.json after removing src (#3648) 2025-09-12 16:20:29 +02:00
Samy Pessé ca71c1fad6 Fix MCP in production (#3647) 2025-09-12 14:59:47 +02:00
Samy Pessé 262afa357b Implement an MCP server on each docs site (#3641) 2025-09-12 13:19:16 +02:00
Samy Pessé 7375d3c597 Version Packages (#3645)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 11:37:22 +02:00
spastorelli 3548fa6dff Fix eval estree expr cjs named import when gitbook/expr is imported in playwright tests (#3643) 2025-09-12 11:20:33 +02:00
conico974 cb73040e0f Bump Next.js to version 15.4.0 (#3644)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-12 11:17:43 +02:00
Samy Pessé 872d36b64f Version Packages (#3629)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-11 18:06:31 +02:00
spastorelli e1ff17e655 Fix bundling of @gitbook/expr package (#3639) 2025-09-11 17:24:06 +02:00
Utku Ufuk f3010bd28a Stop using deprecated search API param (#3638) 2025-09-11 15:26:35 +03:00
spastorelli 8ff1e3b619 Port latest changes from gbx util-expr (#3636) 2025-09-09 18:55:41 +02:00
Viktor Renkema d7596bf454 Don't show toolbar when rendering within GitBook app preview (#3635) 2025-09-09 15:09:52 +02:00
Zeno Kapitein aea5eb10ae Persist language choice across sections (#3633) 2025-09-09 15:06:28 +02:00
Zeno Kapitein 1165a81cf5 Language selector fixes (#3632) 2025-09-08 17:22:42 +02:00
conico974 61d1a0192e Fix null contentRef handling in RecordColumnValue (#3566)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-08 13:54:58 +02:00
Zeno Kapitein f9a2977621 Better handling for external link "mailto:" in Hovered Card in GBO (#3630) 2025-09-08 10:31:30 +00:00
spastorelli 24f601d594 Small optim in resolveTryItPrefillForOperation and remove uneeded dep (#3628) 2025-09-08 10:29:59 +02:00
Samy Pessé 7b34537bbe Version Packages (#3599)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-06 11:40:05 +02:00
conico974 145f385fdb Add pagination for llms-full.txt file (#3619)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-05 17:20:25 +02:00
Greg Bergé 360aa1c6d6 Upgrade Scalar dependencies (#3626) 2025-09-05 16:57:21 +02:00
conico974 ccc0975240 Fix error on parameters being an object instead of an array (#3623)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-05 16:17:50 +02:00
Zeno Kapitein 791135040a Add language selector to site header (#3622) 2025-09-05 12:15:07 +00:00
spastorelli 12c9d765ff Adapt OpenAPI to eval adaptive exprs & prefill TryIt config (#3607) 2025-09-05 13:39:12 +02:00
Greg Bergé 45207288a1 Support bold in headings (#3609) 2025-09-05 11:47:39 +02:00
Viktor Renkema 177afa5828 Handle non-defined objectFit on card cover images (#3625) 2025-09-05 10:42:57 +02:00
Zeno Kapitein d51b79edbb Fix "Search bar is broken on site with sections when header is disabled" (#3624) 2025-09-04 13:48:08 +02:00
conico974 a2720ac49d Update @opennextjs/cloudflare to version 1.7.1 (#3620)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-03 16:09:27 +02:00
Viktor Renkema 5f6853d812 Support new object-fit property on individual card cover image (#3594) 2025-09-03 09:11:17 +02:00
Zeno Kapitein 17dd382dc3 Add "original" color step and apply to bg-primary-solid elements (#3618) 2025-09-02 16:08:21 +02:00
Nolann B. 98e42cfe79 Handle OpenAPI ignored responses (#3614) 2025-09-01 14:25:35 +02:00
Samy Pessé c890e01004 Fix order in robots.txt preventing indexation of images by Google (#3617) 2025-09-01 11:21:11 +00:00
Samy Pessé 659be551bb Track insight event when embedded assistant is displayed (#3616) 2025-09-01 12:59:40 +02:00
Zeno Kapitein 78a632b7fe Fix Edit on GitHub button doesn't take you to GitHub (#3615) 2025-09-01 12:01:36 +02:00
spastorelli 4927e964b7 Add support for YAML content type in request body/example (#3612) 2025-09-01 10:56:09 +02:00
Samy Pessé 262a9b198b Fix embed script caching (#3611) 2025-08-29 22:35:14 +02:00
Samy Pessé 4f3588240c Unify layout and fix tracking of ask_question (#3610) 2025-08-29 16:10:28 +02:00
Nolann B. abbae3ec4d Remove scroll-nojump utility and update layout structure (#3494)
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2025-08-29 11:22:55 +02:00
conico974 1b8609ac60 fix next start not working (#3608)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-28 16:36:06 +02:00
Samy Pessé 193d591e9f Use space.language as source of truth (#3606) 2025-08-27 18:27:29 +02:00
Zeno Kapitein 1edc5d622a Assistant hold message (#3603) 2025-08-27 13:48:57 +00:00
Zeno Kapitein 61b8507996 OpenAPI: Make empty responses clickable (#3605) 2025-08-27 13:38:24 +00:00
Zeno Kapitein 6f368b5cf3 Fix embed assistant window width on small screens (#3604) 2025-08-27 15:33:37 +02:00
Brett Jephson 9201e2cf52 Add vertical align property to column block (#3596)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-08-27 13:58:26 +01:00
Zeno Kapitein 2e0d706d43 Fix corner radius of active section on "Line" sidebar style (#3601) 2025-08-27 13:15:07 +02:00
Nolann B. 7fefe4919c Catch OpenAPI validation errors (#3602) 2025-08-27 12:29:50 +02:00
Zeno Kapitein 729921f338 Move "Edit on Git" and "Export PDF" actions into page actions dropdown (#3574) 2025-08-27 11:32:55 +02:00
Zeno Kapitein 1839ea2678 Fix content min-height with sections (#3597) 2025-08-27 11:30:22 +02:00
Zeno Kapitein f08dd29364 Fix Search results are not clickable (#3595) 2025-08-26 20:33:37 +00:00
conico974 ca2dfa3065 Rollback to Next.js 15.3 (#3600)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-26 18:26:32 +02:00
Utku Ufuk 10ded437f0 Turn on the "noUncheckedIndexedAccess" flag in all TS config files (#3587) 2025-08-26 19:25:54 +03:00
Zeno Kapitein d1fdc13441 Remove ligatures from Lato font (#3592) 2025-08-26 15:04:22 +00:00
Zeno Kapitein a8fca0e033 Fix custom assistant keyboard shortcut (#3598) 2025-08-26 14:57:08 +00:00
181 changed files with 4439 additions and 2044 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Expose a hook useVisitorSession to access the current user/visitor
+23 -5
View File
@@ -182,9 +182,9 @@ jobs:
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v2'
pagespeed-testing-v2:
browserless-testing-v2-vercel:
runs-on: ubuntu-latest
name: PageSpeed Testing v1
name: Browserless Testing v2 (Vercel)
needs: deploy-v2-vercel
steps:
- name: Checkout
@@ -195,8 +195,26 @@ jobs:
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Run pagespeed tests
run: bun ./packages/gitbook/tests/pagespeed-testing.ts
- name: Run tests
run: cd ./packages/gitbook && bun e2e-browserless
env:
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
# browserless-testing-v2-cloudflare:
# runs-on: ubuntu-latest
# name: Browserless Testing v2 (Cloudflare)
# needs: deploy-v2-cloudflare
# steps:
# - name: Checkout
# uses: actions/checkout@v4
# - name: Setup Bun
# uses: ./.github/composite/setup-bun
# - name: Install dependencies
# run: bun install --frozen-lockfile
# env:
# PUPPETEER_SKIP_DOWNLOAD: 1
# - name: Run tests
# run: cd ./packages/gitbook && bun e2e-browserless
# env:
# BASE_URL: ${{needs.deploy-v2-cloudflare.outputs.deployment-url}}
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
+510 -367
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -34,7 +34,7 @@
"workspaces": {
"packages": ["packages/*"],
"catalog": {
"@gitbook/api": "^0.136.0",
"@gitbook/api": "^0.141.0",
"bidc": "^0.0.2"
}
},
+1
View File
@@ -5,6 +5,7 @@
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
+2 -14
View File
@@ -121,17 +121,6 @@ export function getCacheTag(
}
}
/**
* Get the cache tag for a given URL.
*/
export function getCacheTagForURL(url: string | URL) {
const parsedURL = url instanceof URL ? url : new URL(url);
return getCacheTag({
tag: 'url',
hostname: parsedURL.hostname,
});
}
/**
* Get the tags for a computed content source.
*/
@@ -182,7 +171,7 @@ export function getComputedContentSourceCacheTags(
break;
default:
// Do not throw for unknown dependency types
// as it might mean we are lacking behind the API version
// as it might mean we are lagging behind the API version
break;
}
});
@@ -198,9 +187,8 @@ export function getComputedContentSourceCacheTags(
}
// We invalidate the computed content when a new version of the integration is deployed.
if (source.type.startsWith('integration:')) {
const integration = source.type.split(':')[1];
const integration = source.type.split(':')[1]!;
tags.push(
getCacheTag({
tag: 'integration',
+1
View File
@@ -5,6 +5,7 @@
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
+10
View File
@@ -1,5 +1,15 @@
# @gitbook/colors
## 0.4.0
### Minor Changes
- 17dd382: Add `original` background color step
### Patch Changes
- 193d591: Fix return type for `colorContrast`
## 0.3.3
### Patch Changes
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js"
}
},
"version": "0.3.3",
"version": "0.4.0",
"devDependencies": {
"typescript": "^5.5.3"
},
+16 -11
View File
@@ -8,7 +8,7 @@ type RGBColor = [number, number, number];
type OKLABColor = { L: number; A: number; B: number };
type OKLCHColor = { L: number; C: number; H: number };
const D65 = [95.047, 100.0, 108.883]; // Reference white (D65)
const D65 = [95.047, 100.0, 108.883] as const; // Reference white (D65)
export enum ColorCategory {
backgrounds = 'backgrounds',
@@ -19,7 +19,7 @@ export enum ColorCategory {
}
type ColorSubScale = {
[key: string]: number;
[key: string]: number | string;
};
/**
@@ -56,6 +56,8 @@ export const scale: Record<ColorCategory, ColorSubScale> = {
solid: 9,
/** Hovered solid backgrounds */
'solid-hover': 10,
/** Original color */
original: 'original',
},
[ColorCategory.text]: {
/** Very low-contrast text
@@ -211,8 +213,8 @@ export function colorScale(
const result = [];
for (let index = 0; index < mapping.length; index++) {
const targetL =
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
const step = mapping[index]!;
const targetL = foregroundColor.L * step + backgroundColor.L * (1 - step);
if (
index === 8 &&
@@ -295,7 +297,7 @@ export function rgbArrayToHex(rgb: RGBColor): string {
export function getColor(percentage: number, start: RGBColor, end: RGBColor) {
const rgb = end.map((channel, index) => {
return Math.round(channel + percentage * (start[index] - channel));
return Math.round(channel + percentage * (start[index]! - channel));
});
return rgbArrayToHex(rgb as RGBColor);
@@ -392,14 +394,14 @@ export function xyzToLab65(xyz: [number, number, number]): {
B: number;
} {
const [x, y, z] = xyz.map((v, i) => {
const scaled = v / D65[i];
const scaled = v / D65[i]!;
return scaled > 0.008856 ? Math.cbrt(scaled) : 7.787 * scaled + 16 / 116;
});
return {
L: 116 * y - 16,
A: 500 * (x - y),
B: 200 * (y - z),
L: 116 * y! - 16,
A: 500 * (x! - y!),
B: 200 * (y! - z!),
};
}
@@ -419,7 +421,10 @@ export function dpsContrast(a: RGBColor, b: RGBColor) {
return contrast < 7.5 ? 0 : contrast;
}
export function colorContrast(background: string, foreground: string[] = [LIGHT_BASE, DARK_BASE]) {
export function colorContrast(
background: string,
foreground: string[] = [LIGHT_BASE, DARK_BASE]
): string {
const bg = hexToRgbArray(background);
const best: { color?: RGBColor; contrast: number } = {
@@ -436,5 +441,5 @@ export function colorContrast(background: string, foreground: string[] = [LIGHT_
}
}
return best.color ? rgbArrayToHex(best.color) : foreground[0];
return best.color ? rgbArrayToHex(best.color) : foreground[0] || LIGHT_BASE;
}
+1
View File
@@ -5,6 +5,7 @@
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/embed
## 0.1.1
### Patch Changes
- 6f368b5: Fix embed assistant window width on small screens
## 0.1.0
### Minor Changes
+1 -1
View File
@@ -10,7 +10,7 @@
"react": "./dist/react/index.js"
}
},
"version": "0.1.0",
"version": "0.1.1",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
+2 -1
View File
@@ -2,6 +2,7 @@
--gitbook-widget-top: 1rem;
--gitbook-widget-bottom: 1rem;
--gitbook-widget-right: 1rem;
--gitbook-widget-left: 1rem;
--gitbook-widget-button-height: 46px;
@@ -117,7 +118,7 @@
bottom: var(--gitbook-widget-window-bottom);
right: var(--gitbook-widget-right);
z-index: 9998;
width: var(--gitbook-widget-window-width);
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
background-color: var(--gitbook-widget-background-solid);
border: 1px solid var(--gitbook-widget-border-color);
+22
View File
@@ -1,5 +1,27 @@
# @gitbook/expr
## 1.2.0
### Minor Changes
- 6da3655: Fix exports in gitbook/expr package.json
## 1.1.1
### Patch Changes
- 3548fa6: Fix eval-estree-expr named import
## 1.1.0
### Minor Changes
- e1ff17e: Fix bundling of gitbook/expr package
### Patch Changes
- 8ff1e3b: Add support for every/some array methods
## 1.0.0
### Major Changes
+9 -6
View File
@@ -1,34 +1,37 @@
{
"name": "@gitbook/expr",
"description": "Safely evaluate & parse user-defined GitBook expressions.",
"version": "1.0.0",
"version": "1.2.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"sideEffects": false,
"dependencies": {
"eval-estree-expression": "^2.0.3",
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#9cf28d2",
"acorn": "^8.14.0",
"acorn-loose": "8.4.0",
"acorn-walk": "^8.3.4",
"escodegen": "^2.1.0",
"assert-never": "^1.2.1"
},
"devDependencies": {
"bun-types": "^1.1.20",
"tsdown": "^0.15.0",
"@types/estree": "^1.0.6",
"@babel/types": "^7.26.0",
"@types/json-schema": "^7.0.15"
"@types/json-schema": "^7.0.15",
"@types/escodegen": "^0.0.10"
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"build": "tsdown --project tsconfig.build.json",
"typecheck": "tsc --noEmit",
"unit": "bun test",
"clean": "rm -rf ./dist"
},
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
"files": ["dist", "README.md", "CHANGELOG.md"]
}
@@ -210,6 +210,8 @@ describe('autocomplete', () => {
'visitor.claims.hello.length',
'visitor.claims.hello.at',
'visitor.claims.hello.includes',
'visitor.claims.hello.some',
'visitor.claims.hello.every',
],
},
},
@@ -268,6 +270,8 @@ describe('autocomplete', () => {
'visitor.claims.hello.length',
'visitor.claims.hello.at',
'visitor.claims.hello.includes',
'visitor.claims.hello.some',
'visitor.claims.hello.every',
],
},
},
+20 -2
View File
@@ -42,11 +42,29 @@ describe('ExpressionRuntime', () => {
},
expectedResult: true,
},
{
scenario: 'array method',
condition: 'reviews.every(review => !!review.status)',
inputs: { reviews: [{ status: 'approved' }, { status: 'approved' }] },
expectedResult: true,
},
{
scenario: 'array every',
condition: 'reviews.every(review => review.status === "approved")',
inputs: { reviews: [{ status: 'approved' }, { status: 'approved' }] },
expectedResult: true,
},
{
scenario: 'array map',
condition: '[1, 2, 3].map(n => n * x)',
inputs: { x: 2 },
expectedResult: [2, 4, 6],
},
])(
'should properly evaluate/safeEvaluate a valid conditional expression: $scenario',
({ condition, inputs, expectedResult }) => {
expect(runtime.evaluate(condition, inputs)).toBe(expectedResult);
expect(runtime.safeEvaluate(condition, inputs).value).toBe(expectedResult);
expect(runtime.evaluate(condition, inputs)).toEqual(expectedResult);
expect(runtime.safeEvaluate(condition, inputs).value).toEqual(expectedResult);
}
);
-1
View File
@@ -1,6 +1,5 @@
export * from './errors';
export * from './input-values';
export * from './input-values';
export * from './runtime';
export * from './symbols';
export * from './template';
+4 -1
View File
@@ -9,7 +9,9 @@ import {
tokenizer,
} from 'acorn';
import { parse as parseLoose } from 'acorn-loose';
import { evaluate } from 'eval-estree-expression';
import escodegen from 'escodegen';
import evalESTreeExpr from 'eval-estree-expression';
const { evaluate } = evalESTreeExpr;
import { AutoComplete } from './autocomplete';
import { ExpressionError } from './errors';
@@ -49,6 +51,7 @@ export class ExpressionRuntime {
return evaluate.sync<Expression>(parsed.result, inputs, {
functions: true,
withMembers: true,
generate: escodegen.generate,
});
} catch (error) {
throw error instanceof Error
@@ -338,6 +338,8 @@ describe('ExpressionRuntime', () => {
'visitor.claims.hello.length',
'visitor.claims.hello.at',
'visitor.claims.hello.includes',
'visitor.claims.hello.some',
'visitor.claims.hello.every',
],
});
});
+54
View File
@@ -259,6 +259,60 @@ const StandardLibrary: Partial<
'true if the value searchElement is found within the array (or the part of the array indicated by the index fromIndex, if specified).',
}),
}),
SymbolFunction({
name: 'some',
description:
'Tests whether at least one element in the array passes the test implemented by the provided function.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/some',
args: [
SymbolFunction({
name: 'callback',
description: 'A function that tests each element of the array.',
args: [
{
...arraySymbolDef.items,
name: 'element',
description: 'The current element being processed in the array.',
},
],
returns: SymbolBoolean({
description:
'true if the callback function returns a truthy value for at least one element in the array.',
}),
}),
],
returns: SymbolBoolean({
description:
'true if the callback function returns a truthy value for at least one element in the array.',
}),
}),
SymbolFunction({
name: 'every',
description:
'Tests whether all elements in the array pass the test implemented by the provided function.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/every',
args: [
SymbolFunction({
name: 'callback',
description: 'A function that tests each element of the array.',
args: [
{
...arraySymbolDef.items,
name: 'element',
description: 'The current element being processed in the array.',
},
],
returns: SymbolBoolean({
description:
'true if the callback function returns a truthy value for all elements in the array.',
}),
}),
],
returns: SymbolBoolean({
description:
'true if the callback function returns a truthy value for all elements in the array.',
}),
}),
],
}),
};
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'tsdown';
export default defineConfig([
{
entry: ['src/index.ts'],
dts: true,
format: ['esm'],
},
]);
+1 -1
View File
@@ -14,7 +14,7 @@ declare module 'eval-estree-expression' {
/**
* Enable support for function statements and expressions by enabling the functions option AND by passing the .generate() function from the escodegen library. Default: undefined
*/
generate?: boolean;
generate?: boolean | ((node: any) => string);
/**
* Enable the =~ regex operator to support testing values without using functions (example name =~ /^a.*c$/). Default: true
*/
+2 -2
View File
@@ -67,7 +67,7 @@ function getBestUnicodeRange(text: string, ranges: Record<string, string>): stri
const body = token.slice(2); // drop "U+"
const [startHex, endHex] = body.split('-');
const start = Number.parseInt(startHex, 16);
const start = Number.parseInt(startHex!, 16);
const end = endHex ? Number.parseInt(endHex, 16) : start;
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null;
@@ -92,7 +92,7 @@ function getBestUnicodeRange(text: string, ranges: Record<string, string>): stri
for (const [label, rangesArr] of Object.entries(parsed)) {
if (rangesArr.some(([lo, hi]) => cp >= lo && cp <= hi)) {
hits[label]++;
hits[label]!++;
}
}
}
+1
View File
@@ -4,6 +4,7 @@
"module": "ESNext",
"target": "es2022",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "bundler",
+69
View File
@@ -1,5 +1,74 @@
# gitbook
## 0.18.0
### Minor Changes
- 262afa3: Expose a MCP server for the docs site under /~gitbook/mcp
### Patch Changes
- Updated dependencies [262afa3]
- @gitbook/icons@0.3.1
- @gitbook/react-openapi@1.4.3
- @gitbook/react-contentkit@0.7.5
## 0.17.2
### Patch Changes
- @gitbook/react-openapi@1.4.2
## 0.17.1
### Patch Changes
- 24f601d: Small optim in resolveTryItPrefillForOperation
- aea5eb1: Persist language choice across sections if possible
- 1165a81: Language selector edge cases
- f9a2977: Better handling for external link "mailto:" in Hovered Card in GBO
- Updated dependencies [24f601d]
- @gitbook/react-openapi@1.4.1
## 0.17.0
### Minor Changes
- 12c9d76: Adapt OpenAPI blocks to eval adaptive exprs & prefill TryIt config
- 7911350: Add language selector to site header
- 659be55: Track insight event when embedded assistant is displayed.
### Patch Changes
- 2e0d706: Fix corner radius of active section on "Line" sidebar style
- 78a632b: Fix edit on GitHub button doesn't take you to GitHub
- 1edc5d6: Add "hold message" to Assistant
- 4520728: Support bold in headings
- 61b8507: OpenAPI: Make responses without objects clickable
- d1fdc13: Remove ligatures from Lato font
- a8fca0e: Fix custom assistant keyboard shortcut
- 262a9b1: Fix embed script and assets being cached for too long
- c890e01: Fix order in robots.txt preventing indexation of images by Google.
- 1839ea2: Fix content min-height with sections
- 9201e2c: Adds vertical align to column block
- 193d591: Use space language as source of truth for UI locale
- f08dd29: Fix Search results are not clickable on sites without header
- 17dd382: Add `original` background color step
- 4f35882: Fix event ask_question not being tracked
- d51b79e: Fix Search bar is broken on site with sections when header is disabled
- 6f368b5: Fix embed assistant window width on small screens
- Updated dependencies [193d591]
- Updated dependencies [12c9d76]
- Updated dependencies [4927e96]
- Updated dependencies [61b8507]
- Updated dependencies [7fefe49]
- Updated dependencies [360aa1c]
- Updated dependencies [98e42cf]
- Updated dependencies [17dd382]
- @gitbook/colors@0.4.0
- @gitbook/react-openapi@1.4.0
- @gitbook/openapi-parser@3.0.2
## 0.16.0
### Minor Changes
+1
View File
@@ -336,6 +336,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
pageActions: {
externalAI: true,
markdown: true,
mcp: true,
},
trademark: {
enabled: true,
@@ -17,7 +17,8 @@
"dev": {
"vars": {
"STAGE": "dev",
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
"GITBOOK_URL": "http://localhost:8771"
},
"r2_buckets": [
{
@@ -11,7 +11,7 @@ const args = process.argv.slice(2);
const versionId = args[0];
// The preview URL is in the format https://<versionId>-gitbook-open-v2-server-preview.gitbook.workers.dev
const previewHostname = `${versionId.split('-')[0]}-gitbook-open-v2-server-preview.gitbook.workers.dev`;
const previewHostname = `${versionId?.split('-')[0]}-gitbook-open-v2-server-preview.gitbook.workers.dev`;
let updatedFile = file.replace(
/"PREVIEW_HOSTNAME": "TO_REPLACE"/,
+11 -8
View File
@@ -1,12 +1,12 @@
{
"name": "gitbook",
"version": "0.16.0",
"version": "0.18.0",
"private": true,
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/browser-types": "workspace:*",
"@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*",
"@gitbook/browser-types": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/fonts": "workspace:*",
"@gitbook/icons": "workspace:*",
@@ -14,7 +14,7 @@
"@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*",
"@opennextjs/cloudflare": "^1.6.4",
"@opennextjs/cloudflare": "^1.7.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.15",
@@ -27,6 +27,7 @@
"@tusbar/cache-control": "^1.0.2",
"ai": "^4.2.2",
"assert-never": "^1.2.1",
"bidc": "catalog:",
"bun-types": "^1.1.20",
"classnames": "^2.5.1",
"direction": "^2.0.1",
@@ -45,7 +46,7 @@
"memoizee": "^0.4.17",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"next": "^15.5.0",
"next": "15.4.0",
"next-themes": "^0.2.1",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -71,7 +72,9 @@
"usehooks-ts": "^3.1.0",
"warn-once": "^0.1.1",
"zustand": "^5.0.3",
"bidc": "catalog:"
"mcp-handler": "^1.0.2",
"@modelcontextprotocol/sdk": "^1.17.5",
"zod": "^3"
},
"devDependencies": {
"@argos-ci/playwright": "^5.0.9",
@@ -84,7 +87,6 @@
"@types/node": "^20",
"@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4",
"@types/psi": "^4.1.6",
"@types/react": "18.3.13",
"@types/react-dom": "18.3.1",
"@types/rison": "^0.0.9",
@@ -92,7 +94,6 @@
"env-cmd": "^10.1.0",
"jsonwebtoken": "^9.0.2",
"postcss": "^8",
"psi": "^4.1.0",
"stylelint": "^16.16.0",
"tailwindcss": "^4.1.11",
"ts-essentials": "^10.0.1",
@@ -104,7 +105,8 @@
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
"dev": "env-cmd --silent -f ../../.env.local next",
"build": "next build",
"start": "next start",
"build:local": "GITBOOK_URL=http://localhost:3000 next build",
"start": "GITBOOK_URL=http://localhost:3000 next start",
"build:cloudflare": "opennextjs-cloudflare build",
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
@@ -112,6 +114,7 @@
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts --project=chromium",
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
"e2e-browserless": "bun test ./tests/",
"typecheck": "tsc --noEmit"
},
"browserslist": [
@@ -22,7 +22,7 @@ export default async function SiteDynamicLayout({
const withTracking = shouldTrackEvents(await headers());
return (
<CustomizationRootLayout forcedTheme={forcedTheme} customization={context.customization}>
<CustomizationRootLayout forcedTheme={forcedTheme} context={context}>
<SiteLayout
context={context}
forcedTheme={forcedTheme}
@@ -5,6 +5,8 @@ import {
generateEmbeddableViewport,
} from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { shouldTrackEvents } from '@/lib/tracking';
import { headers } from 'next/headers';
interface SiteStaticLayoutProps {
params: Promise<RouteLayoutParams>;
@@ -14,9 +16,18 @@ export default async function RootLayout({
params,
children,
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
const { context } = await getEmbeddableStaticContext(await params);
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
const withTracking = shouldTrackEvents(await headers());
return <EmbeddableRootLayout context={context}>{children}</EmbeddableRootLayout>;
return (
<EmbeddableRootLayout
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
>
{children}
</EmbeddableRootLayout>
);
}
export async function generateViewport({ params }: SiteStaticLayoutProps) {
@@ -19,7 +19,7 @@ export default async function SiteStaticLayout({
const withTracking = shouldTrackEvents();
return (
<CustomizationRootLayout customization={context.customization}>
<CustomizationRootLayout context={context}>
<SiteLayout
context={context}
withTracking={withTracking}
@@ -0,0 +1,20 @@
import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { serveLLMsFullTxt } from '@/routes/llms-full';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams & { page: string }> }
) {
const awaitedParams = await params;
const page = Number(awaitedParams.page);
// If page is not a number, not an integer, or less than 0, return an error
if (Number.isNaN(page) || !Number.isInteger(page) || page < 0) {
return new Response('Invalid page', { status: 400 });
}
const { context } = await getStaticSiteContext(awaitedParams);
return serveLLMsFullTxt(context, page);
}
@@ -5,6 +5,7 @@ import {
generateEmbeddableViewport,
} from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { shouldTrackEvents } from '@/lib/tracking';
interface SiteStaticLayoutProps {
params: Promise<RouteLayoutParams>;
@@ -14,9 +15,18 @@ export default async function RootLayout({
params,
children,
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
const { context } = await getEmbeddableStaticContext(await params);
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
const withTracking = shouldTrackEvents();
return <EmbeddableRootLayout context={context}>{children}</EmbeddableRootLayout>;
return (
<EmbeddableRootLayout
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
>
{children}
</EmbeddableRootLayout>
);
}
export async function generateViewport({ params }: SiteStaticLayoutProps) {
@@ -1,5 +1,6 @@
import type { RouteLayoutParams } from '@/app/utils';
import { getAssetURL } from '@/lib/assets';
import { buildVersion } from '@/lib/build';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import type { CreateGitBookOptions } from '@gitbook/embed';
import type { NextRequest } from 'next/server';
@@ -14,7 +15,6 @@ export async function GET(
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getEmbeddableStaticContext(await params);
const maxAge = 7 * 24 * 60 * 60;
const initOptions: CreateGitBookOptions = {
siteURL: context.linker.toAbsoluteURL(context.linker.toPathInSite('')),
};
@@ -45,13 +45,13 @@ export async function GET(
const load = function () {
const style = document.createElement('link');
style.rel = 'stylesheet';
style.href = ${JSON.stringify(getAssetURL('embed/index.css'))};
style.href = ${JSON.stringify(getAssetURL(`embed/index.css?v=${buildVersion()}`))};
document.head.appendChild(style);
const script = d.createElement('script');
script.type = 'text/javascript';
script.async = true;
script.src = ${JSON.stringify(getAssetURL('embed/index.js'))};
script.src = ${JSON.stringify(getAssetURL(`embed/index.js?v=${buildVersion()}`))};
var latestScript = d.getElementsByTagName('script')[0];
latestScript.parentNode.insertBefore(script, latestScript);
@@ -70,7 +70,7 @@ export async function GET(
{
headers: {
'Content-Type': 'application/javascript',
'Cache-Control': `public, max-age=${maxAge}, immutable`,
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
},
}
);
@@ -0,0 +1,92 @@
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { throwIfDataError } from '@/lib/data';
import { joinPathWithBaseURL } from '@/lib/paths';
import { findSiteSpaceBy } from '@/lib/sites';
import { createMcpHandler } from 'mcp-handler';
import type { NextRequest } from 'next/server';
import { z } from 'zod';
async function handler(
nextRequest: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
const { dataFetcher, linker, site } = context;
const mcpHandler = createMcpHandler(
(server) => {
server.tool(
'searchDocumentation',
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
{
query: z.string(),
},
async ({ query }) => {
const results = await throwIfDataError(
dataFetcher.searchSiteContent({
organizationId: context.organizationId,
siteId: site.id,
query,
scope: { mode: 'all' },
})
);
return {
content: results.flatMap((spaceResult) => {
const found = findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === spaceResult.id
);
const spaceURL = found?.siteSpace.urls.published;
if (!spaceURL) {
return [];
}
return spaceResult.pages.map((pageResult) => {
const pageURL = linker.toAbsoluteURL(
linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, pageResult.path)
)
);
const body = pageResult.sections
?.map((section) => section.body)
.join('\n');
return {
type: 'text',
text: [
`Title: ${pageResult.title}`,
`Link: ${pageURL}`,
body ? `Content: ${body}` : '',
]
.filter(Boolean)
.join('\n'),
};
});
}),
};
}
);
},
{},
{
basePath: context.linker.toPathInSite('~gitbook/'),
streamableHttpEndpoint: '/mcp',
maxDuration: 60,
verboseLogs: true,
disableSse: true,
}
);
// Next.js request.url is the original URL and not the rewritten one from the middleware
const requestURL = new URL(
context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/mcp'))
);
requestURL.search = nextRequest.nextUrl.search;
const request = new Request(requestURL, nextRequest);
return mcpHandler(request);
}
export { handler as GET, handler as POST };
@@ -1,2 +1,3 @@
export * from './useAI';
export * from './useAIChat';
export type { RenderAIMessageOptions } from './server-actions';
@@ -72,7 +72,7 @@ function DescriptionForPageContentToolCall(props: {
}) {
const { toolCall, context } = props;
const language = getSpaceLanguage(context.customization);
const language = getSpaceLanguage(context);
return (
<p>
@@ -102,7 +102,7 @@ function DescriptionForMCPToolCall(props: {
}) {
const { toolCall, context } = props;
const language = getSpaceLanguage(context.customization);
const language = getSpaceLanguage(context);
return (
<p>
@@ -130,7 +130,7 @@ async function DescriptionForSearchToolCall(props: {
}) {
const { toolCall, context } = props;
const language = getSpaceLanguage(context.customization);
const language = getSpaceLanguage(context);
// Resolve all hrefs for search results in parallel
const searchResultsWithHrefs = await Promise.all(
@@ -187,7 +187,7 @@ async function DescriptionForSearchToolCall(props: {
) : null}
</summary>
{hasResults ? (
<div className="mt-1 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
<div className="hide-scrollbar mt-1 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
<ol className="space-y-1">
{searchResultsWithHrefs.map((result, index) => (
<li
@@ -241,7 +241,7 @@ function DescriptionForGetPagesToolCall(props: {
}) {
const { toolCall, context } = props;
const language = getSpaceLanguage(context.customization);
const language = getSpaceLanguage(context);
return (
<p>
@@ -39,7 +39,9 @@ export async function streamRenderAIMessage(
if (message.steps[stepIndex]) {
message.steps = [...message.steps];
// @ts-expect-error
message.steps[stepIndex] = { ...message.steps[stepIndex] };
// @ts-expect-error
callback(message.steps[stepIndex]);
} else {
message.steps = [
@@ -52,6 +54,7 @@ export async function streamRenderAIMessage(
},
},
];
// @ts-expect-error
callback(message.steps[stepIndex]);
}
};
@@ -1,135 +0,0 @@
'use client';
import {
CopyMarkdown,
OpenAIAssistant,
OpenInLLM,
ViewAsMarkdown,
} from '@/components/AIActions/AIActions';
import { Button, ButtonGroup } from '@/components/primitives/Button';
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
import { tString, useLanguage } from '@/intl/client';
import type { SiteCustomizationSettings } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { useRef } from 'react';
import { useAI } from '../AI';
interface AIActionsDropdownProps {
markdownPageUrl: string;
className?: string;
actions: SiteCustomizationSettings['pageActions'];
}
/**
* 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);
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
const language = useLanguage();
return assistants.length > 0 || props.actions.markdown || props.actions.externalAI ? (
<ButtonGroup ref={ref} className={props.className}>
<DefaultAction {...props} />
{props.actions.markdown || props.actions.externalAI ? (
<DropdownMenu
align="end"
className="!min-w-60 max-w-max"
button={
<Button
icon={
<Icon
icon="chevron-down"
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
/>
}
label={tString(language, 'more')}
iconOnly
size="xsmall"
variant="secondary"
className="bg-tint-base text-sm"
/>
}
>
<AIActionsDropdownMenuContent {...props} />
</DropdownMenu>
) : null}
</ButtonGroup>
) : null;
}
/**
* The content of the dropdown menu.
*/
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
const { markdownPageUrl, actions } = props;
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
return (
<>
{assistants.map((assistant) => (
<OpenAIAssistant
key={assistant.label}
assistant={assistant}
type="dropdown-menu-item"
/>
))}
{actions.markdown ? (
<>
<DropdownMenuSeparator className="first:hidden" />
<CopyMarkdown
isDefaultAction={!assistants.length}
markdownPageUrl={markdownPageUrl}
type="dropdown-menu-item"
/>
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
</>
) : null}
{actions.externalAI ? (
<>
<DropdownMenuSeparator className="first:hidden" />
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="dropdown-menu-item" />
<OpenInLLM provider="claude" url={markdownPageUrl} type="dropdown-menu-item" />
</>
) : null}
</>
);
}
/**
* A default action shown as a quick-access button beside the dropdown menu
*/
function DefaultAction(props: AIActionsDropdownProps) {
const { markdownPageUrl, actions } = props;
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
if (assistants.length) {
return <OpenAIAssistant assistant={assistants[0]} type="button" />;
}
if (actions.markdown) {
return (
<CopyMarkdown
isDefaultAction={!assistants.length}
markdownPageUrl={markdownPageUrl}
type="button"
/>
);
}
if (actions.externalAI) {
return (
<>
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="button" />
</>
);
}
}
@@ -1,8 +0,0 @@
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>
);
}
@@ -1,19 +0,0 @@
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>
);
}
@@ -1,9 +0,0 @@
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>
);
}
@@ -20,6 +20,7 @@ import {
EmbeddableFrameSubtitle,
EmbeddableFrameTitle,
} from '../Embeddable/EmbeddableFrame';
import { useTrackEvent } from '../Insights';
import { useNow } from '../hooks';
import { Button } from '../primitives';
import { AIChatControlButton } from './AIChatControlButton';
@@ -46,17 +47,6 @@ export function AIChat(props: { trademark: boolean }) {
}
}, [chat.opened]);
useHotkeys(
'mod+i',
(e) => {
e.preventDefault();
chatController.open();
},
{
enableOnFormTags: true,
}
);
useHotkeys(
'esc',
() => {
@@ -65,6 +55,16 @@ export function AIChat(props: { trademark: boolean }) {
[]
);
// Track the view of the AI chat
const trackEvent = useTrackEvent();
React.useEffect(() => {
if (chat.opened) {
trackEvent({
type: 'ask_view',
});
}
}, [chat.opened, trackEvent]);
if (!chat.opened) {
return null;
}
@@ -224,7 +224,7 @@ export function AIChatBody(props: {
<>
<div
ref={scrollContainerRef}
className="flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4"
className="gutter-stable flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4"
style={{
paddingBottom: `${inputHeight}px`,
}}
@@ -12,8 +12,9 @@ import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
export function AIChatButton(props: {
assistant: Assistant;
showLabel?: boolean;
withShortcut?: boolean;
}) {
const { assistant, showLabel = true } = props;
const { assistant, showLabel = true, withShortcut = true } = props;
const language = useLanguage();
return (
@@ -27,7 +28,12 @@ export function AIChatButton(props: {
label={
<div className="flex items-center gap-2">
{t(language, 'ai_chat_ask', assistant.label)}
<KeyboardShortcut keys={['mod', 'i']} className="border-tint-11 text-tint-1" />
{withShortcut ? (
<KeyboardShortcut
keys={['mod', 'i']}
className="border-tint-11 text-tint-1"
/>
) : null}
</div>
}
onClick={() => assistant.open()}
@@ -1,3 +1,5 @@
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { AIMessageRole } from '@gitbook/api';
import type React from 'react';
@@ -46,17 +48,9 @@ export function AIChatMessages(props: {
{message.content ? message.content : null}
{isLastMessage && chat.loading ? (
<div className="flex w-full animate-fade-in-slow flex-wrap gap-2">
{Array.from({ length: 7 }).map((_, index) => (
<div
key={index}
className="h-4 animate-[fadeIn_500ms_ease_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-4"
style={{
width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
animationDelay: `${index * 0.1}s`,
}}
/>
))}
<div className="flex w-full animate-fade-in-slow flex-col gap-2">
{!message.content ? <HoldMessage /> : null}
<LoadingSkeleton />
</div>
) : null}
@@ -86,3 +80,55 @@ export function AIChatMessages(props: {
</>
);
}
function HoldMessage() {
const language = useLanguage();
return (
<div className="animate-[heightIn_500ms_4500ms_ease_both] py-2 text-tint-subtle">
{tString(language, 'ai_chat_hold_message_1')
.split(' ')
.map((word, index) => (
<span
key={index}
className="animate-fade-in-slow"
style={{
animationDelay: `${5000 + index * 200}ms`,
}}
>
{word}{' '}
</span>
))}
{tString(language, 'ai_chat_hold_message_2')
.split(' ')
.map((word, index) => (
<span
key={index}
className="animate-fade-in-slow"
style={{
animationDelay: `${10000 + index * 200}ms`,
}}
>
{word}{' '}
</span>
))}
</div>
);
}
function LoadingSkeleton() {
return (
<div className="flex flex-wrap gap-2">
{Array.from({ length: 7 }).map((_, index) => (
<div
key={index}
className="h-4 animate-[fadeIn_500ms_ease_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-4"
style={{
width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
animationDelay: `${index * 0.1}s`,
}}
/>
))}
</div>
);
}
@@ -0,0 +1,103 @@
'use client';
import type { GitBookSiteContext } from '@/lib/context';
import { OpenAPIPrefillContextProvider } from '@gitbook/react-openapi';
import * as React from 'react';
import { createContext, useContext } from 'react';
export type AdaptiveVisitorClaimsData = {
visitor: {
claims: Record<string, unknown> & { unsigned: Record<string, unknown> };
};
};
/**
* In-memory cache of visitor claim readers keyed by contextId.
*/
const adaptiveVisitorReaderCache = new Map<
string,
ReturnType<typeof createResourceReader<AdaptiveVisitorClaimsData | null>>
>();
function createResourceReader<T>(promise: Promise<T>) {
let result: T | null | undefined;
const suspender = (async () => {
try {
result = await promise;
} catch {
result = null;
}
})();
return {
read() {
if (result === undefined) {
throw suspender;
}
return result;
},
};
}
/**
* Return an adaptive visitor claims cached reader for a given endpoint URL and contextId.
*/
function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
let reader = adaptiveVisitorReaderCache.get(contextId);
if (!reader) {
const promise = (async () => {
try {
const res = await fetch(url);
if (!res.ok) {
return null;
}
return await res.json<AdaptiveVisitorClaimsData>();
} catch {
return null;
}
})();
reader = createResourceReader(promise);
adaptiveVisitorReaderCache.set(contextId, reader);
}
return reader;
}
export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaimsData | null;
const AdaptiveVisitorContext = createContext<AdaptiveVisitorContextValue>(() => null);
/**
* Provide context to adapt site based on visitor claims.
*/
export function AdaptiveVisitorContextProvider(
props: React.PropsWithChildren<{
visitorClaimsURL: string;
contextId: GitBookSiteContext['contextId'] | undefined;
}>
) {
const { visitorClaimsURL, contextId, children } = props;
const getAdaptiveVisitorClaims = React.useCallback(() => {
if (!contextId) {
return null;
}
return getAdaptiveVisitorClaimsReader(visitorClaimsURL, contextId).read();
}, [visitorClaimsURL, contextId]);
return (
<AdaptiveVisitorContext.Provider value={getAdaptiveVisitorClaims}>
<OpenAPIPrefillContextProvider getPrefillInputContextData={getAdaptiveVisitorClaims}>
{children}
</OpenAPIPrefillContextProvider>
</AdaptiveVisitorContext.Provider>
);
}
/**
* Hook that returns a suspensable getter for adaptive visitor claims data.
*/
export function useAdaptiveVisitor(): AdaptiveVisitorContextValue {
return useContext(AdaptiveVisitorContext);
}
@@ -0,0 +1 @@
export * from './AdaptiveVisitorContextProvider';
@@ -1,11 +1,10 @@
import type { GitBookSiteContext } from '@/lib/context';
import { Icon } from '@gitbook/icons';
import { headers } from 'next/headers';
import React from 'react';
import { tcls } from '@/lib/tailwind';
import { DateRelative } from '../primitives';
import { IframeWrapper } from './IframeWrapper';
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
import { Toolbar, ToolbarBody, ToolbarButton, ToolbarButtonGroups } from './Toolbar';
@@ -47,19 +46,21 @@ function ToolbarLayout(props: { children: React.ReactNode }) {
*/
export async function AdminToolbar(props: AdminToolbarProps) {
const { context } = props;
const mode = (await headers()).get('x-gitbook-mode');
if (mode === 'multi-id') {
// We don't show the admin toolbar in multi-id mode, as it's used for previewing in the dashboard.
return null;
}
if (context.changeRequest) {
return <ChangeRequestToolbar context={context} />;
return (
<IframeWrapper>
<ChangeRequestToolbar context={context} />
</IframeWrapper>
);
}
if (context.revisionId !== context.space.revision) {
return <RevisionToolbar context={context} />;
return (
<IframeWrapper>
<RevisionToolbar context={context} />
</IframeWrapper>
);
}
return null;
@@ -0,0 +1,27 @@
'use client';
import React from 'react';
interface IframeWrapperProps {
children: React.ReactNode;
}
/**
* Client component that detects if we're in an iframe and conditionally renders children
*/
export function IframeWrapper({ children }: IframeWrapperProps) {
const [isInIframe, setIsInIframe] = React.useState(false);
React.useEffect(() => {
// Check if we're running inside an iframe
const inIframe = window !== window.parent;
setIsInIframe(inIframe);
}, []);
// Don't render children if we're in an iframe (GitBook app preview)
if (isInIframe) {
return null;
}
return children;
}
@@ -1 +1,2 @@
export * from './AdminToolbar';
export * from './IframeWrapper';
@@ -34,69 +34,67 @@ export function AnnouncementBanner(props: {
className="theme-bold:bg-header-background pt-4 pb-2"
data-nosnippet=""
>
<div className="scroll-nojump">
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className={tcls('relative', CONTAINER_STYLE)}>
<Tag
href={contentRef?.href ?? ''}
className={tcls(
'flex w-full items-start justify-center overflow-hidden circular-corners:rounded-xl rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
style.container,
closeable && 'pr-12',
hasLink && style.hover
)}
insights={
announcement.link
? {
type: 'link_click',
link: {
target: announcement.link.to,
position: SiteInsightsLinkPosition.Announcement,
},
}
: undefined
}
>
<Icon
icon={style.icon as IconName}
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
/>
<div>
{announcement.message}
{hasLink ? (
<div className={tcls(LinkStyles, style.link, 'ml-1 inline')}>
{contentRef?.icon ? (
<span className="mr-1 ml-2 *:inline">
{contentRef?.icon}
</span>
) : null}
{announcement.link?.title && (
<span className="mr-1">{announcement.link?.title}</span>
)}
<Icon
icon={
announcement.link?.to.kind === 'url'
? 'arrow-up-right'
: 'chevron-right'
}
className={tcls('mb-0.5 inline size-3')}
/>
</div>
) : null}
</div>
</Tag>
{closeable ? (
<Button
iconOnly
icon="close"
label={tString(language, 'close')}
variant="blank"
size="default"
onClick={dismissAnnouncement}
className={`absolute top-0 right-4 mt-2 mr-2 circular-corners:rounded-lg rounded-sm straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`}
/>
) : null}
</div>
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className={tcls('relative', CONTAINER_STYLE)}>
<Tag
href={contentRef?.href ?? ''}
className={tcls(
'flex w-full items-start justify-center overflow-hidden circular-corners:rounded-xl rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
style.container,
closeable && 'pr-12',
hasLink && style.hover
)}
insights={
announcement.link
? {
type: 'link_click',
link: {
target: announcement.link.to,
position: SiteInsightsLinkPosition.Announcement,
},
}
: undefined
}
>
<Icon
icon={style.icon as IconName}
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
/>
<div>
{announcement.message}
{hasLink ? (
<div className={tcls(LinkStyles, style.link, 'ml-1 inline')}>
{contentRef?.icon ? (
<span className="mr-1 ml-2 *:inline">
{contentRef?.icon}
</span>
) : null}
{announcement.link?.title && (
<span className="mr-1">{announcement.link?.title}</span>
)}
<Icon
icon={
announcement.link?.to.kind === 'url'
? 'arrow-up-right'
: 'chevron-right'
}
className={tcls('mb-0.5 inline size-3')}
/>
</div>
) : null}
</div>
</Tag>
{closeable ? (
<Button
iconOnly
icon="close"
label={tString(language, 'close')}
variant="blank"
size="default"
onClick={dismissAnnouncement}
className={`absolute top-0 right-4 mt-2 mr-2 circular-corners:rounded-lg rounded-sm straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`}
/>
) : null}
</div>
</div>
</div>
@@ -119,7 +119,7 @@ export async function highlight(
currentIndex += 1; // for the \n
return {
highlighted: Boolean(lineBlock.data.highlighted),
highlighted: Boolean(lineBlock?.data.highlighted),
tokens: result,
};
});
@@ -1,5 +1,5 @@
import { type ClassValue, tcls } from '@/lib/tailwind';
import type { DocumentBlockColumns, Length } from '@gitbook/api';
import { tcls } from '@/lib/tailwind';
import { type DocumentBlockColumns, type Length, VerticalAlignment } from '@gitbook/api';
import type { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
@@ -8,10 +8,12 @@ export function Columns(props: BlockProps<DocumentBlockColumns>) {
return (
<div className={tcls('flex flex-col gap-x-8 md:flex-row', style)}>
{block.nodes.map((columnBlock) => {
const width = columnBlock.data.width;
const { className, style } = transformLengthToCSS(width);
return (
<Column key={columnBlock.key} className={className} style={style}>
<Column
key={columnBlock.key}
width={columnBlock.data.width}
verticalAlignment={columnBlock.data.verticalAlignment}
>
<Blocks
key={columnBlock.key}
nodes={columnBlock.nodes}
@@ -29,11 +31,23 @@ export function Columns(props: BlockProps<DocumentBlockColumns>) {
export function Column(props: {
children?: React.ReactNode;
className?: ClassValue;
style?: React.CSSProperties;
width?: Length;
verticalAlignment?: VerticalAlignment;
}) {
const { width, verticalAlignment } = props;
const { className, style } = transformLengthToCSS(width);
return (
<div className={tcls('flex-col', props.className)} style={props.style}>
<div
className={tcls(
'flex flex-col',
(verticalAlignment === VerticalAlignment.Top || !verticalAlignment) &&
'justify-start',
verticalAlignment === VerticalAlignment.Middle && 'justify-center',
verticalAlignment === VerticalAlignment.Bottom && 'justify-end',
className
)}
style={style}
>
{props.children}
</div>
);
@@ -7,13 +7,16 @@ import { Block, type BlockProps } from './Block';
import { Blocks } from './Blocks';
import { getBlockTextStyle } from './spacing';
export function Hint(props: BlockProps<DocumentBlockHint>) {
const { block, style, ancestorBlocks, ...contextProps } = props;
export function Hint({
block,
style,
ancestorBlocks,
...contextProps
}: BlockProps<DocumentBlockHint>) {
const hintStyle = HINT_STYLES[block.data.style] ?? HINT_STYLES.info;
const firstLine = getBlockTextStyle(block.nodes[0]);
const firstNode = block.nodes[0];
const hasHeading = ['heading-1', 'heading-2', 'heading-3'].includes(block.nodes[0].type);
const firstNode = block.nodes[0]!;
const firstLine = getBlockTextStyle(firstNode);
const hasHeading = ['heading-1', 'heading-2', 'heading-3'].includes(firstNode.type);
return (
<div
@@ -20,10 +20,7 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
: null;
const { contentContext } = context;
const language =
contentContext && 'customization' in contentContext
? getSpaceLanguage(contentContext.customization)
: languages.en;
const language = contentContext ? getSpaceLanguage(contentContext) : languages.en;
if (!contentContext || !resolved) {
return (
@@ -49,6 +46,7 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
);
}
const isExternal = inline.data.ref.kind === 'url';
const isMailto = resolved.href.startsWith('mailto:');
const content = (
<StyledLink
href={resolved.href}
@@ -66,7 +64,12 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
{isExternal ? (
{isMailto ? (
<Icon
icon="envelope"
className="ml-1 inline size-3 links-accent:text-tint-subtle"
/>
) : isExternal ? (
<Icon
icon="arrow-up-right"
className="ml-0.5 inline size-3 links-accent:text-tint-subtle"
@@ -99,16 +102,24 @@ function InlineLinkTooltipWrapper(props: {
const { inline, language, resolved, children } = props;
let breadcrumbs = resolved.ancestors ?? [];
const isMailto = resolved.href.startsWith('mailto:');
const isExternal = inline.data.ref.kind === 'url';
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
if (isExternal) {
if (isMailto) {
resolved.text = resolved.text.split('mailto:')[1] ?? resolved.text;
breadcrumbs = [
{
label: tString(language, 'link_tooltip_email'),
},
];
} else if (isExternal) {
breadcrumbs = [
{
label: tString(language, 'link_tooltip_external_link'),
},
];
}
if (isSamePage) {
} else if (isSamePage) {
breadcrumbs = [
{
label: tString(language, 'link_tooltip_page_anchor'),
@@ -99,7 +99,7 @@
}
.contentkit-button-style-primary {
@apply bg-primary-solid text-contrast-primary-solid hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover ring-0 contrast-more:ring-1;
@apply bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover ring-0 contrast-more:ring-1;
}
.contentkit-button-style-secondary {
@@ -19,9 +19,9 @@ export function ListItem(props: BlockProps<DocumentBlockListItem>) {
const parent = ancestorBlocks[ancestorBlocks.length - 1];
assert(
(parent && parent.type === 'list-ordered') ||
parent.type === 'list-unordered' ||
parent.type === 'list-tasks',
parent?.type === 'list-ordered' ||
parent?.type === 'list-unordered' ||
parent?.type === 'list-tasks',
'Invalid parent list type'
);
@@ -112,11 +112,11 @@ function getListItemDepth(input: {
for (let i = ancestorBlocks.length - 1; i >= 0; i--) {
const block = ancestorBlocks[i];
if (block.type === type) {
if (block?.type === type) {
depth = depth + 1;
continue;
}
if (block.type === 'list-item') {
if (block?.type === 'list-item') {
continue;
}
break;
@@ -11,7 +11,7 @@ import { Heading } from '../Heading';
import './scalar.css';
import './style.css';
import { DEFAULT_LOCALE, getCustomizationLocale } from '@/intl/server';
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
import type { GitBookAnyContext } from '@/lib/context';
import type {
AnyOpenAPIOperationsBlock,
@@ -30,10 +30,7 @@ export function getOpenAPIContext(args: {
const { props, specUrl, context } = args;
const { block } = props;
const customization = context && 'customization' in context ? context.customization : null;
const customizationLocale = customization
? getCustomizationLocale(customization)
: DEFAULT_LOCALE;
const customizationLocale = context ? getSpaceLocale(context) : DEFAULT_LOCALE;
const locale = checkIsValidLocale(customizationLocale) ? customizationLocale : DEFAULT_LOCALE;
return {
@@ -272,9 +272,8 @@ body {
}
.scalar-activate-button {
@apply flex gap-2 items-center;
@apply bg-primary-solid text-contrast-primary-solid hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-md straight-corners:rounded-none circular-corners:rounded-full circular-corners:px-3 place-self-start;
@apply ring-1 ring-tint hover:ring-tint-hover;
@apply shadow-sm shadow-tint dark:shadow-tint-1 hover:shadow-md active:shadow-none;
@apply bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-md straight-corners:rounded-none circular-corners:rounded-full circular-corners:px-3 place-self-start;
@apply shadow-sm shadow-primary dark:shadow-primary-1 hover:shadow-md active:shadow-none;
@apply contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12;
@apply hover:scale-105 active:scale-100 transition-all;
@apply grow-0 shrink-0 truncate;
@@ -565,7 +565,7 @@
}
.openapi-example-empty {
@apply relative text-tint bg-tint min-h-20 flex flex-col justify-center items-center;
@apply relative text-tint min-h-20 flex flex-col justify-center items-center text-sm;
}
/* Common Elements */
@@ -746,6 +746,10 @@ body:has(.openapi-select-popover) {
@apply px-3 transition-all;
}
.openapi-disclosure-group-trigger[aria-expanded="true"] > .openapi-disclosure-group-label {
@apply font-medium;
}
.openapi-disclosure-group-trigger[aria-expanded="true"] > .openapi-disclosure-group-icon > svg {
@apply rotate-90;
}
@@ -10,7 +10,7 @@ export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
const { block, style, ancestorBlocks, ...contextProps } = props;
const ancestor = ancestorBlocks[ancestorBlocks.length - 1];
assert(ancestor.type === 'stepper', 'Ancestor block must be a stepper');
assert(ancestor?.type === 'stepper', 'Ancestor block must be a stepper');
const index = ancestor.nodes.indexOf(block);
@@ -36,8 +36,8 @@ export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
<div className="relative select-none">
<div
className={tcls(
'can-override-bg can-override-text flex size-[calc(1.75rem+1px)] items-center justify-center rounded-full bg-primary-solid theme-muted:bg-primary-subtle tabular-nums',
'font-medium text-contrast-primary-solid theme-muted:text-primary'
'can-override-bg can-override-text flex size-[calc(1.75rem+1px)] items-center justify-center rounded-full bg-primary-original theme-muted:bg-primary-subtle tabular-nums contrast-more:bg-primary-solid',
'font-medium text-contrast-primary-original theme-muted:text-primary contrast-more:text-contrast-primary-solid'
)}
>
{index + 1}
@@ -3,6 +3,7 @@ import { Image } from '@/components/utils';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import {
CardsImageObjectFit,
type ContentRef,
type DocumentTableViewCards,
SiteInsightsLinkPosition,
@@ -25,8 +26,12 @@ export async function RecordCard(
: null;
const [lightCover, darkCover, target] = await Promise.all([
light && context.contentContext ? resolveContentRef(light, context.contentContext) : null,
dark && context.contentContext ? resolveContentRef(dark, context.contentContext) : null,
light.contentRef && context.contentContext
? resolveContentRef(light.contentRef, context.contentContext)
: null,
dark.contentRef && context.contentContext
? resolveContentRef(dark.contentRef, context.contentContext)
: null,
targetRef && context.contentContext
? resolveContentRef(targetRef, context.contentContext)
: null,
@@ -35,6 +40,10 @@ export async function RecordCard(
const darkCoverIsSquareOrPortrait = isSquareOrPortrait(darkCover);
const lightCoverIsSquareOrPortrait = isSquareOrPortrait(lightCover);
const darkObjectFit = `dark:${getObjectFitClass(dark.objectFit)}`;
const lightObjectFit = getObjectFitClass(light.objectFit);
const objectFits = `${lightObjectFit} ${darkObjectFit}`;
const body = (
<div
className={tcls(
@@ -94,7 +103,7 @@ export async function RecordCard(
'min-w-0',
'w-full',
'h-full',
'object-cover',
'bg-tint-subtle',
lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
? [
lightCoverIsSquareOrPortrait
@@ -104,7 +113,8 @@ export async function RecordCard(
? 'dark:min-[432px]:aspect-video dark:min-[432px]:h-auto'
: '',
].filter(Boolean)
: ['h-auto', 'aspect-video']
: ['h-auto', 'aspect-video'],
objectFits
)}
priority={isOffscreen ? 'lazy' : 'high'}
preload
@@ -190,3 +200,23 @@ function isSquareOrPortrait(contentRef: ResolvedContentRef | null) {
return file.dimensions?.width / file.dimensions?.height <= 1;
}
/**
* Get the CSS class for object-fit based on the objectFit value.
*/
function getObjectFitClass(objectFit: CardsImageObjectFit | undefined): string {
if (!objectFit) {
return 'object-cover';
}
switch (objectFit) {
case CardsImageObjectFit.Contain:
return 'object-contain';
case CardsImageObjectFit.Fill:
return 'object-fill';
case CardsImageObjectFit.Cover:
return 'object-cover';
default:
throw new Error(`Unsupported object fit: ${objectFit}`);
}
}
@@ -15,12 +15,17 @@ import { getSimplifiedContentType } from '@/lib/files';
import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { filterOutNullable } from '@/lib/typescript';
import type { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
import { FileIcon } from '../FileIcon';
import type { TableRecordKV } from './Table';
import { type VerticalAlignment, getColumnAlignment } from './utils';
import {
type VerticalAlignment,
getColumnAlignment,
isContentRef,
isDocumentTableImageRecord,
isStringArray,
} from './utils';
const alignmentMap: Record<'text-left' | 'text-center' | 'text-right', string> = {
'text-left': '**:text-left text-left',
@@ -58,18 +63,28 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
return null;
}
// Because definition and value depends on column, we have to check typing in each case at runtime.
// Validation should have been done at the API level, but we can't know typing based on `definition.type`.
// OpenAPI types cannot really handle discriminated unions based on a dynamic key.
switch (definition.type) {
case 'checkbox':
case 'checkbox': {
if (value === null || typeof value !== 'boolean') {
return null;
}
return (
<Checkbox
className={tcls('w-5', 'h-5')}
checked={value as boolean}
checked={value}
disabled={true}
aria-labelledby={ariaLabelledBy}
/>
);
}
case 'rating': {
const rating = value as number;
if (typeof value !== 'number') {
return null;
}
const rating = value;
const max = definition.max;
return (
@@ -108,15 +123,21 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
</Tag>
);
}
case 'number':
case 'number': {
if (typeof value !== 'number') {
return null;
}
return (
<Tag
className={tcls('text-base', 'tabular-nums', 'tracking-tighter')}
aria-labelledby={ariaLabelledBy}
>{`${value}`}</Tag>
);
}
case 'text': {
// @ts-ignore
if (typeof value !== 'string') {
return null;
}
const fragment = getNodeFragmentByName(block, value);
if (!fragment) {
return <Tag className={tcls(['w-full', verticalAlignment])}>{''}</Tag>;
@@ -149,8 +170,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
);
}
case 'files': {
if (!isStringArray(value)) {
return null;
}
const files = await Promise.all(
(value as string[]).map((fileId) =>
value.map((fileId) =>
context.contentContext
? resolveContentRef(
{
@@ -221,10 +245,12 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
);
}
case 'content-ref': {
const contentRef = value ? (value as ContentRef) : null;
if (value === null || !isContentRef(value)) {
return null;
}
const resolved =
contentRef && context.contentContext
? await resolveContentRef(contentRef, context.contentContext, {
value && context.contentContext
? await resolveContentRef(value, context.contentContext, {
resolveAnchorText: true,
iconStyle: ['mr-2', 'text-tint-subtle'],
})
@@ -239,11 +265,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
<StyledLink
href={resolved.href}
insights={
contentRef
value
? {
type: 'link_click',
link: {
target: contentRef,
target: value,
position: SiteInsightsLinkPosition.Content,
},
}
@@ -257,8 +283,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
);
}
case 'users': {
if (!isStringArray(value)) {
return null;
}
const resolved = await Promise.all(
(value as string[]).map(async (userId) => {
value.map(async (userId) => {
const contentRef: ContentRefUser = {
kind: 'user',
user: userId,
@@ -295,10 +324,13 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
);
}
case 'select': {
if (!isStringArray(value)) {
return null;
}
return (
<Tag aria-labelledby={ariaLabelledBy}>
<span className={tcls('inline-flex', 'gap-2', 'flex-wrap')}>
{(value as string[]).map((selectId) => {
{value.map((selectId) => {
const option = definition.options.find(
(option) => option.value === selectId
);
@@ -329,8 +361,15 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
);
}
case 'image': {
if (!isDocumentTableImageRecord(value)) {
return null;
}
const image = context.contentContext
? await resolveContentRef(value as ContentRef, context.contentContext)
? await resolveContentRef(
'ref' in value ? value.ref : value,
context.contentContext
)
: null;
if (!image) {
@@ -26,6 +26,7 @@ export function RecordRow(
autoSizedColumns,
fixedColumns,
});
// @ts-expect-error
const verticalAlignment = getColumnVerticalAlignment(block.data.definition[column]);
return (
@@ -25,7 +25,9 @@ export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) {
/* Only show the header when configured and not empty */
const withHeader =
!view.hideHeader &&
view.columns.some((columnId) => block.data.definition[columnId].title.trim().length > 0);
view.columns.some(
(columnId) => (block.data.definition[columnId]?.title.trim().length ?? 0) > 0
);
return (
<div className={tcls(style, styles.tableWrapper)}>
@@ -43,28 +45,31 @@ export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) {
)}
>
<div role="row" className={tcls('flex', 'w-full')}>
{view.columns.map((column) => (
<div
key={column}
role="columnheader"
className={tcls(
styles.columnHeader,
getColumnAlignment(block.data.definition[column])
)}
style={{
width: getColumnWidth({
column,
columnWidths,
autoSizedColumns,
fixedColumns,
}),
minWidth: columnWidths?.[column] || '100px',
}}
title={block.data.definition[column].title}
>
{block.data.definition[column].title}
</div>
))}
{view.columns.map((column) => {
const definition = block.data.definition[column]!;
return (
<div
key={column}
role="columnheader"
className={tcls(
styles.columnHeader,
getColumnAlignment(definition)
)}
style={{
width: getColumnWidth({
column,
columnWidths,
autoSizedColumns,
fixedColumns,
}),
minWidth: columnWidths?.[column] || '100px',
}}
title={definition.title}
>
{definition.title}
</div>
);
})}
</div>
</div>
)}
@@ -1,8 +1,10 @@
import type {
CardsImageObjectFit,
ContentRef,
ContentRefFile,
ContentRefURL,
DocumentTableDefinition,
DocumentTableImageRecord,
DocumentTableRecord,
DocumentTableViewCards,
} from '@gitbook/api';
@@ -21,53 +23,64 @@ export function getRecordValue<T extends number | string | boolean | string[] |
/**
* Get the covers for a record card.
* Returns both the light and dark covers.
* Returns both the light and dark covers with their content refs and optional object fit.
* The light cover is a string or a content ref (image or files column type).
* The dark cover is a content ref (image column type).
*/
export function getRecordCardCovers(
record: DocumentTableRecord,
view: DocumentTableViewCards
): { [key in 'light' | 'dark']: ContentRefFile | ContentRefURL | null } {
return {
light: (() => {
if (!view.coverDefinition) {
return null;
}
const value = getRecordValue(record, view.coverDefinition) as
| ContentRefFile
| ContentRefURL
| string[];
if (Array.isArray(value)) {
if (value.length === 0) {
return null;
}
if (typeof value[0] === 'string') {
return { kind: 'file', file: value[0] };
}
}
return value as ContentRefFile | ContentRefURL;
})(),
dark: (() => {
if (!view.coverDefinitionDark) {
return null;
}
const value = getRecordValue(record, view.coverDefinitionDark) as
| ContentRefFile
| ContentRefURL;
if (!value) {
return null;
}
return value;
})(),
): {
[key in 'light' | 'dark']: {
contentRef: ContentRefFile | ContentRefURL | null;
objectFit?: CardsImageObjectFit;
};
} {
const lightValue = view.coverDefinition
? (getRecordValue(record, view.coverDefinition) as DocumentTableImageRecord | string[])
: null;
const darkValue = view.coverDefinitionDark
? (getRecordValue(record, view.coverDefinitionDark) as DocumentTableImageRecord)
: null;
return {
light: processCoverValue(lightValue),
dark: processCoverValue(darkValue),
};
}
/**
* Process a cover value and return the content ref and object fit.
*/
function processCoverValue(value: DocumentTableImageRecord | string[] | null | undefined): {
contentRef: ContentRefFile | ContentRefURL | null;
objectFit?: CardsImageObjectFit;
} {
if (!value) {
return { contentRef: null };
}
if (Array.isArray(value)) {
if (value.length === 0) {
return { contentRef: null };
}
if (typeof value[0] === 'string') {
return { contentRef: { kind: 'file', file: value[0] } };
}
}
// Check if it's the new schema with objectFit
if (value && typeof value === 'object' && 'ref' in value && 'objectFit' in value) {
return {
contentRef: value.ref,
objectFit: value.objectFit,
};
}
// It's a direct ContentRef
return { contentRef: value as ContentRefFile | ContentRefURL };
}
/**
@@ -110,3 +123,35 @@ export function getColumnVerticalAlignment(column: DocumentTableDefinition): Ver
return 'self-center';
}
/**
* Check if a value is a ContentRef.
* @param ref The value to check.
* @returns True if the value is a ContentRef, false otherwise.
*/
export function isContentRef(ref?: DocumentTableRecord['values'][string]): ref is ContentRef {
return Boolean(ref && typeof ref === 'object' && 'kind' in ref);
}
/**
* Check if a value is an array of strings.
* @param value The value to check.
* @returns True if the value is an array of strings, false otherwise.
*/
export function isStringArray(value?: DocumentTableRecord['values'][string]): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === 'string');
}
/**
* Check if a value is a DocumentTableImageRecord.
* @param value The value to check.
* @returns True if the value is a DocumentTableImageRecord, false otherwise.
*/
export function isDocumentTableImageRecord(
value?: DocumentTableRecord['values'][string]
): value is DocumentTableImageRecord {
if (isContentRef(value) && (value.kind === 'file' || value.kind === 'url')) {
return true;
}
return Boolean(value && typeof value === 'object' && 'ref' in value && isContentRef(value.ref));
}
@@ -206,7 +206,7 @@ export function DynamicTabs(
'max-w-full',
'truncate',
active.id === tab.id
active?.id === tab.id
? [
'shrink-0',
'active-tab',
@@ -222,7 +222,7 @@ export function DynamicTabs(
<button
type="button"
role="tab"
aria-selected={active.id === tab.id}
aria-selected={active?.id === tab.id}
aria-controls={getTabPanelId(tab.id)}
id={getTabButtonId(tab.id)}
onClick={() => {
@@ -255,7 +255,7 @@ export function DynamicTabs(
role="tabpanel"
id={getTabPanelId(tab.id)}
aria-labelledby={getTabButtonId(tab.id)}
className={tcls('p-4', tab.id !== active.id ? 'hidden' : null)}
className={tcls('p-4', tab.id !== active?.id ? 'hidden' : null)}
>
{tabsBody[index]}
</div>
@@ -59,15 +59,15 @@ interface MarkedLeafProps<Mark extends DocumentTextMark> {
}
function Bold(props: MarkedLeafProps<DocumentMarkBold>) {
return <strong className={tcls('font-bold')}>{props.children}</strong>;
return <strong className="font-bold">{props.children}</strong>;
}
function Italic(props: MarkedLeafProps<DocumentMarkItalic>) {
return <i className={tcls('font-italic')}>{props.children}</i>;
return <i className="font-italic">{props.children}</i>;
}
function Strikethrough(props: MarkedLeafProps<DocumentMarkStrikethrough>) {
return <s className={tcls('line-through')}>{props.children}</s>;
return <s className="line-through">{props.children}</s>;
}
function Keyboard(props: MarkedLeafProps<DocumentMarkKeyboard>) {
@@ -43,9 +43,9 @@ export function getBlockTextStyle(block: DocumentBlock): {
case 'list-ordered':
case 'list-tasks':
case 'list-unordered':
return getBlockTextStyle(block.nodes[0]);
return getBlockTextStyle(block.nodes[0]!);
case 'list-item':
return getBlockTextStyle(block.nodes[0]);
return getBlockTextStyle(block.nodes[0]!);
default:
return {
textSize: 'text-base',
@@ -21,7 +21,7 @@ export function isBlockOffscreen(
const allAncestors = [document, ...ancestorBlocks];
for (let index = allAncestors.length - 1; index >= 0; index--) {
const parent = allAncestors[index];
const parent = allAncestors[index]!;
const offset = getBlockOffset({ block: current, parent });
if (offset > screenHeight) {
@@ -7,6 +7,9 @@ import {
AIChatDynamicIcon,
AIChatSubtitle,
} from '@/components/AIChat';
import * as api from '@gitbook/api';
import React from 'react';
import { useTrackEvent } from '../Insights';
import {
EmbeddableFrame,
EmbeddableFrameBody,
@@ -28,6 +31,20 @@ export function EmbeddableAIChat(props: {
const chatController = useAIChatController();
const configuration = useEmbeddableConfiguration();
// Track the view of the AI chat
const trackEvent = useTrackEvent();
React.useEffect(() => {
trackEvent(
{
type: 'ask_view',
},
{
pageId: null,
displayContext: api.SiteInsightsDisplayContext.Embed,
}
);
}, [trackEvent]);
return (
<EmbeddableFrame>
<EmbeddableFrameHeader>
@@ -2,6 +2,7 @@ import { type PagePathParams, getSitePageData } from '@/components/SitePage';
import { PageBody } from '@/components/PageBody';
import type { GitBookSiteContext } from '@/lib/context';
import { SiteInsightsDisplayContext } from '@gitbook/api';
import type { Metadata } from 'next';
import { Button } from '../primitives';
import {
@@ -54,6 +55,7 @@ export async function EmbeddableDocsPage(props: EmbeddableDocsPageProps) {
ancestors={ancestors}
document={document}
withPageFeedback={withPageFeedback}
insightsDisplayContext={SiteInsightsDisplayContext.Embed}
/>
</div>
</EmbeddableFrameBody>
@@ -1,16 +1,20 @@
import { AIChatProvider, AIContextProvider } from '@/components/AI';
import { AIContextProvider } from '@/components/AI';
import { CustomizationRootLayout } from '@/components/RootLayout';
import {
SiteLayoutClientContexts,
generateSiteLayoutMetadata,
generateSiteLayoutViewport,
} from '@/components/SiteLayout';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import type { GitBookSiteContext } from '@/lib/context';
import { CustomizationAIMode } from '@gitbook/api';
import { SpaceLayoutServerContext } from '../SpaceLayout';
import { EmbeddableIframeAPI } from './EmbeddableIframeAPI';
type EmbeddableRootLayoutProps = {
context: GitBookSiteContext;
withTracking: boolean;
visitorAuthClaims: VisitorAuthClaims;
};
/**
@@ -18,10 +22,12 @@ type EmbeddableRootLayoutProps = {
*/
export async function EmbeddableRootLayout({
context,
withTracking,
visitorAuthClaims,
children,
}: React.PropsWithChildren<EmbeddableRootLayoutProps>) {
return (
<CustomizationRootLayout customization={context.customization}>
<CustomizationRootLayout context={context}>
<SiteLayoutClientContexts
forcedTheme={context.customization.themes.default}
externalLinksTarget={context.customization.externalLinks.target}
@@ -31,8 +37,11 @@ export async function EmbeddableRootLayout({
aiMode={CustomizationAIMode.Assistant}
trademark={context.customization.trademark.enabled}
>
<AIChatProvider
renderMessageOptions={{
<SpaceLayoutServerContext
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
aiChatRenderMessageOptions={{
withLinkPreviews: false,
asEmbeddable: true,
}}
@@ -41,17 +50,17 @@ export async function EmbeddableRootLayout({
<EmbeddableIframeAPI
baseURL={context.linker.toPathInSpace('~gitbook/embed/')}
/>
</AIChatProvider>
</SpaceLayoutServerContext>
</AIContextProvider>
</SiteLayoutClientContexts>
</CustomizationRootLayout>
);
}
export async function generateEmbeddableViewport({ context }: EmbeddableRootLayoutProps) {
export async function generateEmbeddableViewport({ context }: { context: GitBookSiteContext }) {
return generateSiteLayoutViewport(context);
}
export async function generateEmbeddableMetadata({ context }: EmbeddableRootLayoutProps) {
export async function generateEmbeddableMetadata({ context }: { context: GitBookSiteContext }) {
return generateSiteLayoutMetadata(context);
}
+98 -100
View File
@@ -32,111 +32,109 @@ export function Footer(props: { context: GitBookSiteContext }) {
mobileOnly ? 'xl:hidden' : null
)}
>
<div className="scroll-nojump">
<div className={tcls(CONTAINER_STYLE, 'px-4', 'py-8', 'lg:py-12', 'mx-auto')}>
<div
className={tcls(
'mx-auto grid max-w-3xl site-width-wide:max-w-screen-2xl justify-between gap-12 lg:max-w-none!',
'grid-cols-[auto_auto]',
'lg:grid-cols-[18rem_minmax(auto,48rem)_auto]',
'xl:grid-cols-[18rem_minmax(auto,48rem)_14rem]',
'lg:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_auto]',
'xl:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_14rem]',
'lg:page-no-toc:grid-cols-[minmax(auto,48rem)_auto]',
'xl:page-no-toc:grid-cols-[14rem_minmax(auto,48rem)_14rem]',
'lg:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[minmax(auto,90rem)_auto]',
'xl:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[14rem_minmax(auto,90rem)_14rem]'
)}
>
{
// Footer Logo
customization.footer.logo ? (
<div className="col-start-1 row-start-1">
<Image
alt="Logo"
resize={context.imageResizer}
sources={{
light: {
src: customization.footer.logo.light,
},
dark: customization.footer.logo.dark
? {
src: customization.footer.logo.dark,
}
: null,
}}
priority="lazy"
style={[
'w-auto',
'max-w-40',
'lg:max-w-64',
'max-h-10',
'lg:max-h-12',
'object-contain',
'object-left',
'rounded-sm',
'straight-corners:rounded-xs',
]}
sizes={[
{
width: 320,
},
]}
/>
</div>
) : null
}
<div className={tcls(CONTAINER_STYLE, 'px-4', 'py-8', 'lg:py-12', 'mx-auto')}>
<div
className={tcls(
'mx-auto grid max-w-3xl site-width-wide:max-w-screen-2xl justify-between gap-12 lg:max-w-none!',
'grid-cols-[auto_auto]',
'lg:grid-cols-[18rem_minmax(auto,48rem)_auto]',
'xl:grid-cols-[18rem_minmax(auto,48rem)_14rem]',
'lg:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_auto]',
'xl:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_14rem]',
'lg:page-no-toc:grid-cols-[minmax(auto,48rem)_auto]',
'xl:page-no-toc:grid-cols-[14rem_minmax(auto,48rem)_14rem]',
'lg:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[minmax(auto,90rem)_auto]',
'xl:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[14rem_minmax(auto,90rem)_14rem]'
)}
>
{
// Footer Logo
customization.footer.logo ? (
<div className="col-start-1 row-start-1">
<Image
alt="Logo"
resize={context.imageResizer}
sources={{
light: {
src: customization.footer.logo.light,
},
dark: customization.footer.logo.dark
? {
src: customization.footer.logo.dark,
}
: null,
}}
priority="lazy"
style={[
'w-auto',
'max-w-40',
'lg:max-w-64',
'max-h-10',
'lg:max-h-12',
'object-contain',
'object-left',
'rounded-sm',
'straight-corners:rounded-xs',
]}
sizes={[
{
width: 320,
},
]}
/>
</div>
) : null
}
{
// Theme Toggle
customization.themes.toggeable ? (
<div className="-col-start-2 row-start-1 flex items-start justify-end xl:hidden">
<React.Suspense fallback={null}>
<ThemeToggler />
</React.Suspense>
</div>
) : null
}
{
// Theme Toggle
customization.themes.toggeable ? (
<div className="-col-start-2 row-start-1 flex items-start justify-end xl:hidden">
<React.Suspense fallback={null}>
<ThemeToggler />
</React.Suspense>
</div>
) : null
}
{
// Navigation groups (split into equal columns)
customization.footer.groups?.length > 0 ? (
<div
className={tcls(
'col-span-2 lg:page-has-toc:col-span-1 lg:page-has-toc:col-start-2 xl:page-no-toc:col-span-1 xl:page-no-toc:col-start-2'
{
// Navigation groups (split into equal columns)
customization.footer.groups?.length > 0 ? (
<div
className={tcls(
'col-span-2 lg:page-has-toc:col-span-1 lg:page-has-toc:col-start-2 xl:page-no-toc:col-span-1 xl:page-no-toc:col-start-2'
)}
>
<div className="mx-auto flex max-w-3xl site-width-wide:max-w-screen-2xl flex-col gap-10 sm:flex-row sm:gap-6">
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
(column, columnIndex) => (
<div
key={columnIndex}
className="flex flex-1 grow flex-col gap-10"
>
{column.map((group, groupIndex) => (
<FooterLinksGroup
key={groupIndex}
group={group}
context={context}
/>
))}
</div>
)
)}
>
<div className="mx-auto flex max-w-3xl site-width-wide:max-w-screen-2xl flex-col gap-10 sm:flex-row sm:gap-6">
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
(column, columnIndex) => (
<div
key={columnIndex}
className="flex flex-1 grow flex-col gap-10"
>
{column.map((group, groupIndex) => (
<FooterLinksGroup
key={groupIndex}
group={group}
context={context}
/>
))}
</div>
)
)}
</div>
</div>
) : null
}
</div>
) : null
}
{
// Legal
customization.footer.copyright ? (
<div className="order-last col-span-full flex w-full grow flex-col items-center gap-2 text-center text-tint text-xs">
<p>{customization.footer.copyright}</p>
</div>
) : null
}
</div>
{
// Legal
customization.footer.copyright ? (
<div className="order-last col-span-full flex w-full grow flex-col items-center gap-2 text-center text-tint text-xs">
<p>{customization.footer.copyright}</p>
</div>
) : null
}
</div>
</div>
</footer>
+129 -117
View File
@@ -3,13 +3,14 @@ import type { GitBookSiteContext } from '@/lib/context';
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
import { getSpaceLanguage, t } from '@/intl/server';
import { tcls } from '@/lib/tailwind';
import { SearchContainer } from '../Search';
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
import { HeaderLink } from './HeaderLink';
import { HeaderLinkMore } from './HeaderLinkMore';
import { HeaderLinks } from './HeaderLinks';
import { HeaderLogo } from './HeaderLogo';
import { HeaderMobileMenu } from './HeaderMobileMenu';
import { SpacesDropdown } from './SpacesDropdown';
import { TranslationsDropdown } from './SpacesDropdown';
/**
* Render the header for the space.
@@ -17,9 +18,9 @@ import { SpacesDropdown } from './SpacesDropdown';
export function Header(props: {
context: GitBookSiteContext;
withTopHeader?: boolean;
search?: React.ReactNode;
withVariants?: 'generic' | 'translations';
}) {
const { context, withTopHeader, search } = props;
const { context, withTopHeader, withVariants } = props;
const { siteSpace, siteSpaces, sections, customization } = context;
return (
@@ -56,135 +57,146 @@ export function Header(props: {
'theme-bold:shadow-tint-12/2'
)}
>
<div className="scroll-nojump">
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div
className={tcls(
'gap-4',
'lg:gap-6',
'flex',
'items-center',
'justify-between',
'w-full',
'py-3',
'min-h-16',
'sm:h-16',
CONTAINER_STYLE
)}
>
<div
className={tcls(
'gap-4',
'lg:gap-6',
'flex',
'items-center',
'justify-between',
'w-full',
'py-3',
'min-h-16',
'sm:h-16',
CONTAINER_STYLE
'flex max-w-full lg:basis-72',
'min-w-0 shrink items-center justify-start gap-2 lg:gap-4'
)}
>
<div
<HeaderMobileMenu
className={tcls(
'flex max-w-full lg:basis-72',
'min-w-0 shrink items-center justify-start gap-2 lg:gap-4'
'-ml-2',
'text-tint-strong',
'theme-bold:text-header-link',
'hover:bg-tint-hover',
'hover:theme-bold:bg-header-link/3',
withVariants === 'generic'
? 'xl:hidden'
: 'page-no-toc:hidden lg:hidden'
)}
>
<HeaderMobileMenu
className={tcls(
'lg:hidden',
'-ml-2',
'text-tint-strong',
'theme-bold:text-header-link',
'hover:bg-tint-hover',
'hover:theme-bold:bg-header-link/3',
'page-no-toc:hidden'
)}
/>
<HeaderLogo context={context} />
</div>
<div
className={tcls(
'flex',
'grow-0',
'shrink-0',
'md:basis-56',
'justify-self-end',
'items-center',
'gap-2',
'search' in customization.styling &&
customization.styling.search === 'prominent'
? [
'md:grow-[0.8]',
'lg:basis-40',
'md:max-w-[40%]',
'lg:max-w-lg',
'lg:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem)
'xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem)
'md:mr-auto',
'order-last',
'md:order-[unset]',
]
: ['order-last']
)}
>
{search}
</div>
{customization.header.links.length > 0 && (
<HeaderLinks>
{customization.header.links.map((link) => {
return (
<HeaderLink
key={link.title}
link={link}
context={context}
/>
);
})}
<HeaderLinkMore
label={t(getSpaceLanguage(customization), 'more')}
links={customization.header.links}
context={context}
/>
</HeaderLinks>
)}
/>
<HeaderLogo context={context} />
</div>
<div
className={tcls(
'flex',
'grow-0',
'shrink-0',
'md:basis-56',
'justify-self-end',
'items-center',
'gap-2',
'search' in customization.styling &&
customization.styling.search === 'prominent'
? [
'md:grow-[0.8]',
'lg:basis-40',
'md:max-w-[40%]',
'lg:max-w-lg',
'lg:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem)
'xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem)
'md:mr-auto',
'order-last',
'md:order-[unset]',
]
: ['order-last']
)}
>
<SearchContainer
style={customization.styling.search}
isMultiVariants={siteSpaces.length > 1}
spaceTitle={siteSpace.title}
siteSpaceId={siteSpace.id}
viewport={!withTopHeader ? 'mobile' : undefined}
/>
</div>
{customization.header.links.length > 0 ||
(!sections && withVariants === 'translations') ? (
<HeaderLinks>
{customization.header.links.length > 0 ? (
<>
{customization.header.links.map((link) => {
return (
<HeaderLink
key={link.title}
link={link}
context={context}
/>
);
})}
<HeaderLinkMore
label={t(getSpaceLanguage(context), 'more')}
links={customization.header.links}
context={context}
/>
</>
) : null}
{!sections && withVariants === 'translations' ? (
<TranslationsDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
className="flex! theme-bold:text-header-link hover:theme-bold:bg-header-link/3"
/>
) : null}
</HeaderLinks>
) : null}
</div>
</div>
</div>
{sections || siteSpaces.length > 1 ? (
<div className="scroll-nojump">
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
{sections ? (
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div
className={tcls(
'w-full',
'overflow-x-auto',
'no-scrollbar',
'-mb-4 pb-4', // Positive padding / negative margin allows the navigation menu indicator to show in a scroll viewƒ
!sections ? ['hidden', 'page-no-toc:flex'] : 'flex'
)}
>
<div
className={tcls(
'w-full',
'overflow-x-auto',
'no-scrollbar',
'-mb-4 pb-4', // Positive padding / negative margin allows the navigation menu indicator to show in a scroll viewƒ
!sections ? ['hidden', 'page-no-toc:flex'] : 'flex'
CONTAINER_STYLE,
'grow',
'flex',
'items-end',
'page-default-width:2xl:px-[calc((100%-1536px+4rem)/2)]'
)}
>
<div
className={tcls(
CONTAINER_STYLE,
'grow',
'flex',
'items-end',
'page-default-width:2xl:px-[calc((100%-1536px+4rem)/2)]'
)}
>
{siteSpaces.length > 1 && (
<div
id="variants"
className="my-2 mr-5 grow border-tint border-r pr-5 *:grow only:mr-0 only:border-none only:pr-0 sm:max-w-64"
>
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
className="w-full grow py-1"
/>
</div>
)}
{sections &&
(sections.list.some((s) => s.object === 'site-section-group') || // If there's even a single group, show the tabs
sections.list.length > 1) && ( // Otherwise, show the tabs if there's more than one section
<SiteSectionTabs
sections={encodeClientSiteSections(context, sections)}
/>
)}
</div>
{sections.list.some((s) => s.object === 'site-section-group') || // If there's even a single group, show the tabs
sections.list.length > 1 ? ( // Otherwise, show the tabs if there's more than one section
<SiteSectionTabs
sections={encodeClientSiteSections(context, sections)}
/>
) : null}
{withVariants === 'translations' ? (
<div className="site-background before:contents[] -mr-4 sm:-mr-6 md:-mr-8 sticky inset-y-0 right-0 z-10 ml-6 flex h-full items-center py-2 pr-4 before:mr-4 before:h-full before:border-tint before:border-l sm:pr-6 md:pr-8">
<TranslationsDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
/>
</div>
) : null}
</div>
</div>
</div>
@@ -138,7 +138,7 @@ function HeaderItemButton(
function getHeaderLinkClassName(_props: { headerPreset: CustomizationHeaderPreset }) {
return tcls(
'flex items-center',
'flex items-center gap-1',
'shrink',
'contrast-more:underline',
'truncate',
@@ -1,18 +1,29 @@
import type { SiteSpace } from '@gitbook/api';
import { useMemo } from 'react';
import type { GitBookSiteContext } from '@/lib/context';
import { getSiteSpaceURL } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { Button, type ButtonProps } from '../primitives';
import { DropdownChevron, DropdownMenu } from '../primitives/DropdownMenu';
import { SpacesDropdownMenuItems } from './SpacesDropdownMenuItem';
// Memoized regex for checking if a string starts with an emoji
const EMOJI_REGEX = /^\p{Emoji}/u;
function startsWithEmoji(text: string): boolean {
return EMOJI_REGEX.test(text);
}
export function SpacesDropdown(props: {
context: GitBookSiteContext;
siteSpace: SiteSpace;
siteSpaces: SiteSpace[];
className?: string;
variant?: ButtonProps['variant'];
icon?: ButtonProps['icon'];
}) {
const { context, siteSpace, siteSpaces, className } = props;
const { context, siteSpace, siteSpaces, className, variant = 'secondary', icon } = props;
return (
<DropdownMenu
@@ -21,48 +32,16 @@ export function SpacesDropdown(props: {
'group-focus-within/dropdown:group-hover/dropdown:visible' // When the dropdown is already open, it should remain visible when hovered
)}
button={
<div
<Button
icon={icon}
data-testid="space-dropdown-button"
className={tcls(
'flex',
'flex-row',
'items-center',
'transition-all',
'hover:cursor-pointer',
'px-3',
'py-2',
'gap-2',
'rounded-md',
'straight-corners:rounded-none',
'bg-tint-base',
'text-sm',
'text-tint',
'hover:text-tint-strong',
'data-[state=open]:text-tint-strong',
'ring-1',
'ring-tint-subtle',
'hover:ring-tint-hover',
'data-[state=open]:ring-tint-hover',
'contrast-more:bg-tint-base',
'contrast-more:ring-1',
'contrast-more:hover:ring-2',
'contrast-more:data-[state=open]:ring-2',
'contrast-more:ring-tint',
'contrast-more:hover:ring-tint-hover',
'contrast-more:data-[state=open]:ring-tint-hover',
className
)}
size="medium"
variant={variant}
trailing={<DropdownChevron />}
className={tcls('bg-tint-base', className)}
>
<span className={tcls('truncate', 'grow')}>{siteSpace.title}</span>
<DropdownChevron />
</div>
<span className="button-content">{siteSpace.title}</span>
</Button>
}
>
<SpacesDropdownMenuItems
@@ -77,3 +56,32 @@ export function SpacesDropdown(props: {
</DropdownMenu>
);
}
export function TranslationsDropdown(props: {
context: GitBookSiteContext;
siteSpace: SiteSpace;
siteSpaces: SiteSpace[];
className?: string;
}) {
const { context, siteSpace, siteSpaces, className } = props;
// Memoize the emoji check to avoid repeated regex execution
const hasEmojiPrefix = useMemo(() => startsWithEmoji(siteSpace.title), [siteSpace.title]);
return (
<SpacesDropdown
icon="globe"
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
variant="blank"
className={tcls(
'-mx-2 bg-transparent px-2 py-1 lg:max-w-64 max-md:[&_.button-content]:hidden',
hasEmojiPrefix
? 'md:[&_.button-leading-icon]:hidden' // If the title starts with an emoji, don't show the icon (on desktop)
: '',
className
)}
/>
);
}
@@ -8,7 +8,7 @@ import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
import { getAllBrowserCookiesMap } from '@/lib/browser';
import { type CurrentContentContext, useCurrentContent } from '../hooks';
import { getSession } from './sessions';
import { getVisitorId } from './visitorId';
import { type SessionResponse, useVisitorSession } from './visitorId';
export type InsightsEventName = api.SiteInsightsEvent['type'];
@@ -16,6 +16,7 @@ export type InsightsEventName = api.SiteInsightsEvent['type'];
* Context for an event on a page.
*/
export interface InsightsEventPageContext {
displayContext: api.SiteInsightsDisplayContext;
pageId: string | null;
}
@@ -46,18 +47,14 @@ type TrackEventCallback = <EventName extends InsightsEventName>(
options?: InsightsEventOptions
) => void;
const InsightsContext = React.createContext<TrackEventCallback>(() => {});
const InsightsContext = React.createContext<TrackEventCallback>(() => {
console.error('useTrackEvent must be used within an InsightsProvider');
});
interface InsightsProviderProps {
/** If true, the events will be sent to the server. */
enabled: boolean;
/** If true, the visitor cookie tracking will be used */
visitorCookieTrackingEnabled: boolean;
/** The application URL. */
appURL: string;
/** The url of the endpoint to send events to */
eventUrl: string;
@@ -69,10 +66,10 @@ interface InsightsProviderProps {
* Wrap the content of the app with the InsightsProvider to track events.
*/
export function InsightsProvider(props: InsightsProviderProps) {
const { enabled, children, visitorCookieTrackingEnabled, eventUrl, appURL } = props;
const { enabled, children, eventUrl } = props;
const visitorSession = useVisitorSession();
const currentContent = useCurrentContent();
const visitorIdRef = React.useRef<string | null>(null);
const eventsRef = React.useRef<{
[pathname: string]:
| {
@@ -89,9 +86,8 @@ export function InsightsProvider(props: InsightsProviderProps) {
*/
const flushEventsSync = useEventCallback(() => {
const session = getSession();
const visitorId = visitorIdRef.current;
if (!visitorId) {
throw new Error('Visitor ID should be set before flushing events');
if (!visitorSession) {
return;
}
const allEvents: api.SiteInsightsEvent[] = [];
@@ -111,7 +107,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
events: eventsForPathname.events,
context: currentContent,
pageContext: eventsForPathname.pageContext,
visitorId,
visitorSession,
sessionId: session.id,
})
);
@@ -134,14 +130,17 @@ export function InsightsProvider(props: InsightsProviderProps) {
}
});
const flushBatchedEvents = useDebounceCallback(async () => {
const visitorId =
visitorIdRef.current ?? (await getVisitorId(appURL, visitorCookieTrackingEnabled));
visitorIdRef.current = visitorId;
const flushBatchedEvents = useDebounceCallback(() => {
flushEventsSync();
}, 1500);
// Flush events once the visitor session is set
React.useEffect(() => {
if (visitorSession) {
flushEventsSync();
}
}, [visitorSession, flushEventsSync]);
const trackEvent: TrackEventCallback = useEventCallback(
(
event: TrackEventInput<InsightsEventName>,
@@ -166,7 +165,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
if (eventsRef.current[pathname].pageContext !== undefined) {
// If the pageId is set, we know that the page_view event has been tracked
// and we can flush the events
if (options?.immediate && visitorIdRef.current) {
if (options?.immediate) {
flushBatchedEvents.cancel();
flushEventsSync();
} else {
@@ -177,18 +176,14 @@ export function InsightsProvider(props: InsightsProviderProps) {
);
/**
* Get the visitor ID and store it in a ref.
* When the page is unloaded, flush all events.
*/
React.useEffect(() => {
getVisitorId(appURL, visitorCookieTrackingEnabled).then((visitorId) => {
visitorIdRef.current = visitorId;
// When the page is unloaded, flush all events, but only if the visitor ID is set
window.addEventListener('beforeunload', flushEventsSync);
});
window.addEventListener('beforeunload', flushEventsSync);
return () => {
window.removeEventListener('beforeunload', flushEventsSync);
};
}, [flushEventsSync, visitorCookieTrackingEnabled, appURL]);
}, [flushEventsSync]);
return (
<InsightsContext.Provider value={trackEvent}>
@@ -242,12 +237,12 @@ function transformEvents(input: {
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext: InsightsEventPageContext;
visitorId: string;
visitorSession: SessionResponse;
sessionId: string;
}): api.SiteInsightsEvent[] {
const session: api.SiteInsightsEventSession = {
sessionId: input.sessionId,
visitorId: input.visitorId,
visitorId: input.visitorSession.deviceId,
userAgent: window.navigator.userAgent,
language: window.navigator.language,
cookies: getAllBrowserCookiesMap(),
@@ -263,6 +258,7 @@ function transformEvents(input: {
siteShareKey: input.context.siteShareKey ?? null,
revision: input.context.revisionId,
page: input.pageContext.pageId,
displayContext: input.pageContext.displayContext,
};
return input.events.map((partialEvent) => {
@@ -1,5 +1,6 @@
'use client';
import type { SiteInsightsDisplayContext } from '@gitbook/api';
import * as React from 'react';
import { useCurrentPage } from '../hooks';
@@ -8,7 +9,10 @@ import { useTrackEvent } from './InsightsProvider';
/**
* Track a page view event.
*/
export function TrackPageViewEvent() {
export function TrackPageViewEvent(props: {
displayContext: SiteInsightsDisplayContext;
}) {
const { displayContext } = props;
const page = useCurrentPage();
const trackEvent = useTrackEvent();
@@ -19,9 +23,10 @@ export function TrackPageViewEvent() {
},
{
pageId: page?.pageId ?? null,
displayContext,
}
);
}, [page, trackEvent]);
}, [page, trackEvent, displayContext]);
return null;
}
@@ -1,73 +0,0 @@
'use client';
import { getBrowserCookie } from '@/lib/browser';
import { isCookiesTrackingDisabled } from './cookies';
import { generateRandomId } from './utils';
const VISITORID_COOKIE = '__session';
let visitorId: string | null = null;
let pendingVisitorId: Promise<string> | null = null;
/**
* Return the current visitor identifier.
*/
export async function getVisitorId(
appURL: string,
visitorCookieTrackingEnabled: boolean
): Promise<string> {
if (!visitorId) {
if (!pendingVisitorId) {
pendingVisitorId = fetchVisitorID(appURL, visitorCookieTrackingEnabled).finally(() => {
pendingVisitorId = null;
});
}
visitorId = await pendingVisitorId;
}
return visitorId;
}
/**
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
*/
async function fetchVisitorID(
appURL: string,
visitorCookieTrackingEnabled: boolean
): Promise<string> {
const withoutCookies = isCookiesTrackingDisabled();
if (withoutCookies || !visitorCookieTrackingEnabled) {
return generateRandomId();
}
const existingTrackingCookie = getBrowserCookie(VISITORID_COOKIE);
if (existingTrackingCookie) {
// If the cookie already exists, we'll just use that. Avoids a server request.
return existingTrackingCookie;
}
// No tracking deviceId set, we'll need to consolidate with the server.
const proposed = generateRandomId();
const url = new URL(appURL);
url.pathname = '/__session/2/';
url.searchParams.set('proposed', proposed);
try {
const resp = await fetch(url, {
method: 'GET', // Use GET to play nicely with SameSite cookies.
credentials: 'include', // Make sure to send/receive cookies.
cache: 'no-cache',
mode: 'cors', // Need to use cors as we are on a different domain.
});
const { deviceId } = (await resp.json()) as { deviceId: string };
return deviceId;
} catch (error) {
console.error('Failed to fetch visitor session ID', error);
return proposed;
}
}
@@ -0,0 +1,127 @@
'use client';
import { createStore, useStore } from 'zustand';
import { getBrowserCookie } from '@/lib/browser';
import React from 'react';
import { isCookiesTrackingDisabled } from './cookies';
import { generateRandomId } from './utils';
const VISITORID_COOKIE = '__session';
type SessionVisitorResponse = {
deviceId: string;
};
type SessionUserResponse = SessionVisitorResponse & {
userId: string;
organizationId: string;
};
export type SessionResponse = SessionVisitorResponse | SessionUserResponse;
const visitorSessionStore = createStore<{
session: SessionResponse | null;
pendingSession: Promise<SessionResponse> | null;
}>(() => ({
session: null,
pendingSession: null,
}));
/**
* Fetch and provide the visitor session.
*/
export function VisitorSessionProvider(
props: React.PropsWithChildren<{
appURL: string;
visitorCookieTrackingEnabled: boolean;
}>
) {
const { appURL, visitorCookieTrackingEnabled, children } = props;
React.useEffect(() => {
const state = visitorSessionStore.getState();
if (state.pendingSession || state.session) {
return;
}
const pendingSession = fetchSession({ appURL, visitorCookieTrackingEnabled });
visitorSessionStore.setState({ pendingSession, session: null });
pendingSession.then((session) => {
visitorSessionStore.setState({ pendingSession: null, session });
});
}, [appURL, visitorCookieTrackingEnabled]);
return <>{children}</>;
}
/**
* Hook to get the current visitor session.
*/
export function useVisitorSession() {
return useStore(visitorSessionStore, (state) => state.session);
}
/**
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
*/
async function fetchSession({
appURL,
visitorCookieTrackingEnabled,
}: {
appURL: string;
visitorCookieTrackingEnabled: boolean;
}): Promise<SessionResponse> {
const withoutCookies = isCookiesTrackingDisabled();
if (withoutCookies || !visitorCookieTrackingEnabled) {
return {
deviceId: generateRandomId(),
};
}
const existingTrackingCookie = getSessionCookie();
if (existingTrackingCookie) {
// If the cookie already exists, we'll just use that. Avoids a server request.
return existingTrackingCookie;
}
// No tracking deviceId set, we'll need to consolidate with the server.
const proposed = generateRandomId();
const url = new URL(appURL);
url.pathname = '/__session/2/';
url.searchParams.set('proposed', proposed);
try {
const resp = await fetch(url, {
method: 'GET', // Use GET to play nicely with SameSite cookies.
credentials: 'include', // Make sure to send/receive cookies.
cache: 'no-cache',
mode: 'cors', // Need to use cors as we are on a different domain.
});
const session = (await resp.json()) as SessionResponse;
return session;
} catch (error) {
console.error('Failed to fetch visitor session ID', error);
return {
deviceId: proposed,
};
}
}
function getSessionCookie(): SessionResponse | null {
const value = getBrowserCookie(VISITORID_COOKIE);
if (!(value && typeof value === 'string')) {
return null;
}
try {
const parsed = JSON.parse(value) as SessionResponse;
return parsed;
} catch {
return { deviceId: value };
}
}
@@ -57,7 +57,7 @@ export async function PDFPage(props: {
const customization =
'customization' in baseContext ? baseContext.customization : defaultCustomization();
const language = getSpaceLanguage(customization);
const language = getSpaceLanguage(baseContext);
// Compute the pages to render
const { pages, total } = selectPages(baseContext.revision.pages, pdfParams);
@@ -149,8 +149,7 @@ export async function PDFPage(props: {
trademark={
customization.trademark.enabled ? (
<TrademarkLink
space={context.space}
customization={customization}
context={context}
placement={SiteInsightsTrademarkPlacement.Pdf}
/>
) : null
@@ -1,6 +1,5 @@
import { CustomizationRootLayout } from '@/components/RootLayout';
import type { GitBookSiteContext, GitBookSpaceContext } from '@/lib/context';
import { defaultCustomization } from '@/lib/utils';
import { CustomizationThemeMode } from '@gitbook/api';
/**
@@ -13,12 +12,7 @@ export async function PDFRootLayout(props: {
const { context, children } = props;
return (
<CustomizationRootLayout
customization={
'customization' in context ? context.customization : defaultCustomization()
}
forcedTheme={CustomizationThemeMode.Light}
>
<CustomizationRootLayout context={context} forcedTheme={CustomizationThemeMode.Light}>
{children}
</CustomizationRootLayout>
);
@@ -2,31 +2,29 @@
import { useAIChatState } from '@/components/AI';
import type { Assistant } from '@/components/AI';
import { ChatGPTIcon } from '@/components/AIActions/assets/ChatGPTIcon';
import { ClaudeIcon } from '@/components/AIActions/assets/ClaudeIcon';
import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon';
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 type { GitSyncState } from '@gitbook/api';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import assertNever from 'assert-never';
import QuickLRU from 'quick-lru';
import type React from 'react';
import React from 'react';
import { create } from 'zustand';
type AIActionType = 'button' | 'dropdown-menu-item';
type PageActionType = 'button' | 'dropdown-menu-item';
/**
* Opens our AI Docs Assistant.
* Action to open the GitBook Assistant.
*/
export function OpenAIAssistant(props: { assistant: Assistant; type: AIActionType }) {
export function ActionOpenAssistant(props: { assistant: Assistant; type: PageActionType }) {
const { assistant, type } = props;
const chat = useAIChatState();
const language = useLanguage();
return (
<AIActionWrapper
<PageActionWrapper
type={type}
icon={assistant.icon}
label={assistant.label}
@@ -86,14 +84,14 @@ const useCopiedStore = create<
const markdownCache = new QuickLRU<string, string>({ maxSize: 10 });
/**
* Copies the markdown version of the page to the clipboard.
* Action to copy the markdown version of the page to the clipboard.
*/
export function CopyMarkdown(props: {
markdownPageUrl: string;
type: AIActionType;
export function ActionCopyMarkdown(props: {
markdownPageURL: string;
type: PageActionType;
isDefaultAction?: boolean;
}) {
const { markdownPageUrl, type, isDefaultAction } = props;
const { markdownPageURL, type, isDefaultAction } = props;
const language = useLanguage();
const closeDropdown = useDropdownMenuClose();
@@ -104,8 +102,8 @@ export function CopyMarkdown(props: {
const fetchMarkdown = async () => {
setLoading(true);
const result = await fetch(markdownPageUrl).then((res) => res.text());
markdownCache.set(markdownPageUrl, result);
const result = await fetch(markdownPageURL).then((res) => res.text());
markdownCache.set(markdownPageURL, result);
setLoading(false);
@@ -119,7 +117,7 @@ export function CopyMarkdown(props: {
e.preventDefault();
}
copy(markdownCache.get(markdownPageUrl) || (await fetchMarkdown()), {
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) {
@@ -130,7 +128,7 @@ export function CopyMarkdown(props: {
};
return (
<AIActionWrapper
<PageActionWrapper
type={type}
icon={copied ? 'check' : 'copy'}
label={copied ? tString(language, 'code_copied') : tString(language, 'copy_page')}
@@ -145,17 +143,17 @@ export function CopyMarkdown(props: {
/**
* Redirects to the markdown version of the page.
*/
export function ViewAsMarkdown(props: { markdownPageUrl: string; type: AIActionType }) {
const { markdownPageUrl, type } = props;
export function ActionViewAsMarkdown(props: { markdownPageURL: string; type: PageActionType }) {
const { markdownPageURL, type } = props;
const language = useLanguage();
return (
<AIActionWrapper
<PageActionWrapper
type={type}
icon={<MarkdownIcon className="size-4 fill-current" />}
icon="markdown"
label={tString(language, 'view_page_markdown')}
description={tString(language, 'view_page_plaintext')}
href={markdownPageUrl}
href={markdownPageURL}
/>
);
}
@@ -163,10 +161,10 @@ export function ViewAsMarkdown(props: { markdownPageUrl: string; type: AIActionT
/**
* Open the page in a LLM with a pre-filled prompt. Either ChatGPT or Claude.
*/
export function OpenInLLM(props: {
export function ActionOpenInLLM(props: {
provider: 'chatgpt' | 'claude';
url: string;
type: AIActionType;
type: PageActionType;
}) {
const { provider, url, type } = props;
const language = useLanguage();
@@ -174,15 +172,9 @@ export function OpenInLLM(props: {
const providerLabel = provider === 'chatgpt' ? 'ChatGPT' : 'Claude';
return (
<AIActionWrapper
<PageActionWrapper
type={type}
icon={
provider === 'chatgpt' ? (
<ChatGPTIcon className="size-3.5 fill-current" />
) : (
<ClaudeIcon className="size-3.5 fill-current" />
)
}
icon={provider}
label={tString(language, 'open_in', providerLabel)}
shortLabel={providerLabel}
description={tString(language, 'ai_chat_ask_about_page', providerLabel)}
@@ -191,11 +183,134 @@ export function OpenInLLM(props: {
);
}
/**
* Action to open the page in the associated Git repository.
*/
export function ActionOpenEditOnGit(props: {
type: PageActionType;
provider: GitSyncState['installationProvider'];
url: string;
}) {
const { type, provider, url } = props;
const language = useLanguage();
const providerName =
provider === 'github' ? 'GitHub' : provider === 'gitlab' ? 'GitLab' : 'Git';
return (
<PageActionWrapper
type={type}
icon={provider === 'gitlab' ? 'gitlab' : 'github'}
label={tString(language, 'edit_on_git', providerName)}
shortLabel={tString(language, 'edit')}
href={url}
/>
);
}
/**
* Action to copy the MCP URL to the clipboard.
*/
export function ActionCopyMCPURL(props: { mcpURL: string; type: PageActionType }) {
const { mcpURL, type } = props;
const language = useLanguage();
return (
<CopyToClipboard
type={type}
data={mcpURL}
label={tString(language, 'connect_with_mcp')}
description={tString(language, 'copy_mcp_url')}
icon="mcp"
/>
);
}
/**
* Action to open the MCP server in a specific editor.
*/
export function ActionOpenMCP(props: {
siteTitle: string;
mcpURL: string;
provider: 'vscode';
type: PageActionType;
}) {
const { siteTitle, provider, mcpURL, type } = props;
const language = useLanguage();
const providerInfo = React.useMemo<{ label: string; icon: IconName; url: string }>(() => {
switch (provider) {
case 'vscode': {
const vscodeConfig = { name: siteTitle, url: mcpURL };
return {
label: 'VSCode',
icon: 'vscode',
url: `vscode:mcp/install?${encodeURIComponent(JSON.stringify(vscodeConfig))}`,
};
}
default:
assertNever(provider);
}
}, [provider, mcpURL, siteTitle]);
return (
<PageActionWrapper
type={type}
href={providerInfo.url}
label={tString(language, 'connect_mcp_to', providerInfo.label)}
description={tString(language, 'install_mcp_on', providerInfo.label)}
icon={providerInfo.icon}
/>
);
}
/**
* Action to view the page as a PDF.
*/
export function ActionViewAsPDF(props: { url: string; type: PageActionType }) {
const { url, type } = props;
const language = useLanguage();
return (
<PageActionWrapper
type={type}
icon="file-pdf"
label={tString(language, 'pdf_download')}
href={url}
target="_self"
/>
);
}
/**
* Action to copy a string to the clipboard.
*/
export function CopyToClipboard(props: {
type: PageActionType;
data: string;
label: string;
description: string;
icon: IconName;
}) {
const { type, data, label, description, icon } = props;
return (
<PageActionWrapper
type={type}
icon={icon}
label={label}
description={description}
onClick={() => {
navigator.clipboard.writeText(data);
}}
/>
);
}
/**
* Wraps an action in a button (for the default action) or dropdown menu item.
*/
function AIActionWrapper(props: {
type: AIActionType;
function PageActionWrapper(props: {
type: PageActionType;
icon: IconName | React.ReactNode;
label: string;
/**
@@ -205,10 +320,22 @@ function AIActionWrapper(props: {
onClick?: (e: React.MouseEvent) => void;
description?: string;
href?: string;
target?: React.HTMLAttributeAnchorTarget;
disabled?: boolean;
loading?: boolean;
}) {
const { type, icon, label, shortLabel, onClick, href, description, disabled, loading } = props;
const {
type,
icon,
label,
shortLabel,
onClick,
href,
target = '_blank',
description,
disabled,
loading,
} = props;
if (type === 'button') {
return (
@@ -222,7 +349,7 @@ function AIActionWrapper(props: {
className="bg-tint-base text-sm"
onClick={onClick}
href={href}
target={href ? '_blank' : undefined}
target={href ? target : undefined}
disabled={disabled || loading}
>
{shortLabel}
@@ -234,7 +361,7 @@ function AIActionWrapper(props: {
<DropdownMenuItem
className="flex items-stretch gap-2.5 p-2"
href={href}
target="_blank"
target={target}
onClick={onClick}
disabled={disabled || loading}
>
@@ -255,9 +382,11 @@ function AIActionWrapper(props: {
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="flex items-center gap-2 text-tint-strong">
<span className="flex items-center gap-1 text-tint-strong">
<span className="truncate font-medium text-sm">{label}</span>
{href ? <Icon icon="arrow-up-right" className="size-3 shrink-0" /> : null}
{href && target === '_blank' ? (
<Icon icon="arrow-up-right" className="size-3 shrink-0 text-tint-subtle" />
) : null}
</span>
{description && <span className="text-tint text-xs">{description}</span>}
</div>
@@ -0,0 +1,186 @@
'use client';
import { Button, ButtonGroup } from '@/components/primitives/Button';
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
import { tString, useLanguage } from '@/intl/client';
import type { GitSyncState, SiteCustomizationSettings } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import React, { useRef } from 'react';
import { useAI } from '../AI';
import {
ActionCopyMCPURL,
ActionCopyMarkdown,
ActionOpenAssistant,
ActionOpenEditOnGit,
ActionOpenInLLM,
ActionOpenMCP,
ActionViewAsMarkdown,
ActionViewAsPDF,
} from './PageActions';
interface PageActionsDropdownProps {
siteTitle: string;
markdownPageURL: string;
mcpURL?: string;
pdfURL?: string;
className?: string;
actions: SiteCustomizationSettings['pageActions'];
editOnGit?: {
provider: GitSyncState['installationProvider'];
url: string;
};
}
/**
* Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM).
*/
export function PageActionsDropdown(props: PageActionsDropdownProps) {
const ref = useRef<HTMLDivElement>(null);
const language = useLanguage();
const defaultAction = getPageDefaultAction(props);
const dropdownActions = getPageDropdownActions(props);
return defaultAction || dropdownActions.length > 0 ? (
<ButtonGroup ref={ref} className={props.className}>
{defaultAction}
{dropdownActions.length > 0 ? (
<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"
/>
}
label={tString(language, defaultAction ? 'more' : 'actions')}
iconOnly={!!defaultAction}
size="xsmall"
variant="secondary"
className="bg-tint-base text-sm"
/>
}
>
{dropdownActions}
</DropdownMenu>
) : null}
</ButtonGroup>
) : null;
}
/**
* Return the list of actions to show in the dropdown menu.
*/
function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNode[] {
const { siteTitle, markdownPageURL, mcpURL, actions } = props;
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
return [
...assistants.map((assistant) => (
<ActionOpenAssistant
key={assistant.label}
assistant={assistant}
type="dropdown-menu-item"
/>
)),
actions.markdown ? (
<React.Fragment key="markdown">
<DropdownMenuSeparator className="first:hidden" />
<ActionCopyMarkdown
isDefaultAction={!assistants.length}
markdownPageURL={markdownPageURL}
type="dropdown-menu-item"
/>
<ActionViewAsMarkdown markdownPageURL={markdownPageURL} type="dropdown-menu-item" />
</React.Fragment>
) : null,
actions.externalAI ? (
<React.Fragment key="externalAI">
<DropdownMenuSeparator className="first:hidden" />
<ActionOpenInLLM
provider="chatgpt"
url={markdownPageURL}
type="dropdown-menu-item"
/>
<ActionOpenInLLM
provider="claude"
url={markdownPageURL}
type="dropdown-menu-item"
/>
</React.Fragment>
) : null,
actions.mcp && mcpURL ? (
<React.Fragment key="mcp">
<DropdownMenuSeparator className="first:hidden" />
<ActionCopyMCPURL mcpURL={mcpURL} type="dropdown-menu-item" />
<ActionOpenMCP
provider="vscode"
mcpURL={mcpURL}
siteTitle={siteTitle}
type="dropdown-menu-item"
/>
</React.Fragment>
) : null,
props.editOnGit || props.pdfURL ? (
<React.Fragment key="editOnGit">
<DropdownMenuSeparator className="first:hidden" />
{props.editOnGit ? (
<ActionOpenEditOnGit
type="dropdown-menu-item"
provider={props.editOnGit.provider}
url={props.editOnGit.url}
/>
) : null}
{props.pdfURL ? (
<ActionViewAsPDF url={props.pdfURL} type="dropdown-menu-item" />
) : null}
</React.Fragment>
) : null,
].filter(Boolean);
}
/**
* A default action shown as a quick-access button beside the dropdown menu
*/
function getPageDefaultAction(props: PageActionsDropdownProps) {
const { markdownPageURL, actions } = props;
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
const assistant = assistants[0];
if (assistant) {
return <ActionOpenAssistant assistant={assistant} type="button" />;
}
if (props.editOnGit) {
return (
<ActionOpenEditOnGit
type="button"
provider={props.editOnGit.provider}
url={props.editOnGit.url}
/>
);
}
if (actions.markdown) {
return (
<ActionCopyMarkdown
isDefaultAction={!assistant}
markdownPageURL={markdownPageURL}
type="button"
/>
);
}
return null;
}
@@ -25,7 +25,7 @@ export const AsideSectionHighlight = React.memo(
'rounded-md',
'straight-corners:rounded-none',
'circular-corners:rounded-2xl',
'sidebar-list-line:rounded-l-none',
'sidebar-list-line:rounded-l-none!',
'sidebar-list-pill:bg-primary',
'[html.theme-muted.sidebar-list-pill_&]:bg-primary-hover',
@@ -4,18 +4,15 @@ import {
type RevisionPageDocument,
SiteAdsStatus,
SiteInsightsAdPlacement,
type Space,
} from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import React from 'react';
import urlJoin from 'url-join';
import { getSpaceLanguage, t } from '@/intl/server';
import { getDocumentSections } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import { Ad } from '../Ads';
import { getPDFURLSearchParams } from '../PDF';
import { PageFeedbackForm } from '../PageFeedback';
import { ThemeToggler } from '../ThemeToggler';
import { ScrollSectionsList } from './ScrollSectionsList';
@@ -118,11 +115,7 @@ export function PageAside(props: {
<PageAsideSections document={document} context={context} />
</React.Suspense>
) : null}
<PageAsideActions
page={page}
withPageFeedback={withPageFeedback}
context={context}
/>
<PageAsideActions page={page} withPageFeedback={withPageFeedback} />
</div>
) : null}
{customization.themes.toggeable || site.ads ? (
@@ -135,7 +128,7 @@ export function PageAside(props: {
function PageAsideHeader(props: { context: GitBookSiteContext }) {
const { context } = props;
const language = getSpaceLanguage(context.customization);
const language = getSpaceLanguage(context);
return (
<div
@@ -171,34 +164,16 @@ async function PageAsideSections(props: { document: JSONDocument; context: GitBo
function PageAsideActions(props: {
withPageFeedback: boolean;
context: GitBookSiteContext;
page: RevisionPageDocument;
}) {
const { page, withPageFeedback, context } = props;
const { customization, space } = context;
const language = getSpaceLanguage(customization);
const pdfHref = context.linker.toPathInSpace(
`~gitbook/pdf?${getPDFURLSearchParams({
page: page.id,
only: true,
limit: 100,
}).toString()}`
);
const { page, withPageFeedback } = props;
return (
<div
className={tcls(
'flex',
'flex-col',
'gap-3',
'sidebar-list-default:px-3',
'border-t',
'first:border-none',
'border-tint-subtle',
'pt-5',
'first:pt-0',
'xl:max-2xl:page-api-block:p-5',
'flex flex-col gap-3',
'border-tint-subtle border-t first:border-none',
'sidebar-list-default:px-3 pt-5 first:pt-0 xl:max-2xl:page-api-block:p-5',
'empty:hidden'
)}
>
@@ -207,57 +182,6 @@ function PageAsideActions(props: {
<PageFeedbackForm pageId={page.id} />
</React.Suspense>
) : null}
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
<div>
<a
href={urlJoin(space.gitSync.url, page.git.path)}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon
icon={
space.gitSync.installationProvider === 'gitlab'
? 'gitlab'
: 'github'
}
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'edit_on_git', getGitSyncName(space))}
</a>
</div>
) : null}
{customization.pdf.enabled ? (
<div>
<a
href={pdfHref}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon icon="file-pdf" className={tcls('size-4', 'mr-1.5')} />
{t(language, 'pdf_download')}
</a>
</div>
) : null}
</div>
);
}
@@ -296,14 +220,3 @@ async function PageAsideFooter(props: { context: GitBookSiteContext }) {
</div>
);
}
function getGitSyncName(space: Space): string {
if (space.gitSync?.installationProvider === 'github') {
return 'GitHub';
}
if (space.gitSync?.installationProvider === 'gitlab') {
return 'GitLab';
}
return 'Git';
}
@@ -19,14 +19,8 @@ const SECTION_INTERSECTING_THRESHOLD = 0.9;
*/
const ACTIVE_ITEM_OFFSET = 100;
export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
const { sections } = props;
const ids = React.useMemo(() => {
return sections.map((section) => {
return section.id;
});
}, [sections]);
export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }) {
const ids = React.useMemo(() => sections.map(({ id }) => id), [sections]);
const enabled = useBodyLoaded();
@@ -101,7 +95,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
'rounded-md',
'straight-corners:rounded-none',
'circular-corners:rounded-2xl',
'sidebar-list-line:rounded-l-none',
'sidebar-list-line:rounded-l-none!',
'hover:bg-tint-hover',
'theme-gradient:hover:bg-tint-12/1',
@@ -1,5 +1,5 @@
import type { GitBookSiteContext } from '@/lib/context';
import type { JSONDocument, RevisionPageDocument } from '@gitbook/api';
import type { JSONDocument, RevisionPageDocument, SiteInsightsDisplayContext } from '@gitbook/api';
import React from 'react';
import { getSpaceLanguage } from '@/intl/server';
@@ -26,8 +26,9 @@ export function PageBody(props: {
ancestors: AncestorRevisionPage[];
document: JSONDocument | null;
withPageFeedback: boolean;
insightsDisplayContext: SiteInsightsDisplayContext;
}) {
const { page, context, ancestors, document, withPageFeedback } = props;
const { page, context, ancestors, document, withPageFeedback, insightsDisplayContext } = props;
const { customization } = context;
const contentFullWidth = document ? hasFullWidthBlock(document) : false;
@@ -42,7 +43,7 @@ export function PageBody(props: {
: false;
const pageWidthWide = page.layout.width === 'wide';
const siteWidthWide = pageWidthWide || contentFullWidth;
const language = getSpaceLanguage(customization);
const language = getSpaceLanguage(context);
const updatedAt = page.updatedAt ?? page.createdAt;
return (
@@ -117,7 +118,7 @@ export function PageBody(props: {
}
</main>
<TrackPageViewEvent />
<TrackPageViewEvent displayContext={insightsDisplayContext} />
</CurrentPageProvider>
);
}
@@ -17,9 +17,9 @@ export async function PageFooterNavigation(props: {
page: RevisionPageDocument;
}) {
const { context, page } = props;
const { customization, revision, linker } = context;
const { revision, linker } = context;
const { previous, next } = resolvePrevNextPages(revision.pages, page);
const language = getSpaceLanguage(customization);
const language = getSpaceLanguage(context);
const previousHref = previous
? linker.toPathForPage({ pages: revision.pages, page: previous })
: '';
@@ -1,9 +1,11 @@
import { AIActionsDropdown } from '@/components/AIActions/AIActionsDropdown';
import type { GitBookSiteContext } from '@/lib/context';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import type { RevisionPageDocument } from '@gitbook/api';
import { type RevisionPageDocument, SiteVisibility } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import urlJoin from 'url-join';
import { getPDFURLSearchParams } from '../PDF';
import { PageActionsDropdown } from '../PageActions/PageActionsDropdown';
import { PageIcon } from '../PageIcon';
import { StyledLink } from '../primitives';
@@ -19,8 +21,6 @@ export async function PageHeader(props: {
return null;
}
const pageActions = context.customization.pageActions;
const hasAncestors = ancestors.length > 0;
return (
@@ -38,9 +38,38 @@ export async function PageHeader(props: {
>
{page.layout.tableOfContents ? (
// Show page actions if *any* of the actions are enabled
<AIActionsDropdown
markdownPageUrl={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`}
actions={pageActions}
<PageActionsDropdown
siteTitle={context.site.title}
markdownPageURL={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`}
editOnGit={
context.customization.git.showEditLink &&
context.space.gitSync?.url &&
page.git
? {
provider: context.space?.gitSync?.installationProvider,
url: urlJoin(context.space.gitSync.url, page.git.path),
}
: undefined
}
pdfURL={
context.customization.pdf.enabled
? context.linker.toPathInSpace(
`~gitbook/pdf?${getPDFURLSearchParams({
page: page.id,
only: true,
limit: 100,
}).toString()}`
)
: undefined
}
mcpURL={
context.site.visibility !== SiteVisibility.VisitorAuth
? context.linker.toAbsoluteURL(
context.linker.toPathInSpace('~gitbook/mcp')
)
: undefined
}
actions={context.customization.pageActions}
className={tcls(
'float-right ml-4 xl:max-2xl:page-api-block:mr-62',
hasAncestors ? '-my-1.5' : '-mt-3 xs:mt-2'
@@ -31,7 +31,9 @@ import { RootLayoutClientContexts } from './RootLayoutClientContexts';
import '@gitbook/icons/style.css';
import './globals.css';
import type { GitBookAnyContext } from '@/lib/context';
import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@/lib/env';
import { defaultCustomization } from '@/lib/utils';
import { AnnouncementDismissedScript } from '../Announcement';
function preloadFont(fontData: FontData) {
@@ -56,12 +58,14 @@ function preloadFont(fontData: FontData) {
*/
export async function CustomizationRootLayout(props: {
forcedTheme?: CustomizationThemeMode | null;
customization: SiteCustomizationSettings;
context: GitBookAnyContext;
children: React.ReactNode;
}) {
const { customization, forcedTheme, children } = props;
const { context, forcedTheme, children } = props;
const customization =
'customization' in context ? context.customization : defaultCustomization();
const language = getSpaceLanguage(customization);
const language = getSpaceLanguage(context);
const tintColor = getTintColor(customization);
const mixColor = getTintMixColor(customization.styling.primaryColor, tintColor);
const sidebarStyles = getSidebarStyles(customization);
@@ -94,7 +98,9 @@ export async function CustomizationRootLayout(props: {
'depth' in customization.styling && `depth-${customization.styling.depth}`,
fontNotoColorEmoji.variable,
monospaceFontData.type === 'default' ? monospaceFontData.variable : null,
fontData.type === 'default' ? fontData.variable : null,
fontData.type === 'default'
? [fontData.variable, `font-${customization.styling.font}`]
: null,
// Set the dark/light class statically to avoid flashing and make it work when JS is disabled
(forcedTheme ?? customization.themes.default) === CustomizationThemeMode.Dark
@@ -138,7 +144,12 @@ export async function CustomizationRootLayout(props: {
customization.styling.primaryColor.light
)
};
--header-link: ${hexToRgb(customization.header.linkColor?.light ?? colorContrast(tintColor?.light ?? customization.styling.primaryColor.light))};
--header-link: ${hexToRgb(
customization.header.linkColor?.light ??
colorContrast(
tintColor?.light ?? customization.styling.primaryColor.light
)
)};
${generateColorVariable('info', infoColor.light)}
${generateColorVariable('warning', warningColor.light)}
@@ -152,7 +163,12 @@ export async function CustomizationRootLayout(props: {
${generateColorVariable('neutral', DEFAULT_TINT_COLOR, { darkMode: true })}
--header-background: ${hexToRgb(customization.header.backgroundColor?.dark ?? tintColor?.dark ?? customization.styling.primaryColor.dark)};
--header-link: ${hexToRgb(customization.header.linkColor?.dark ?? colorContrast(tintColor?.dark ?? customization.styling.primaryColor.dark))};
--header-link: ${hexToRgb(
customization.header.linkColor?.dark ??
colorContrast(
tintColor?.dark ?? customization.styling.primaryColor.dark
)
)};
${generateColorVariable('info', infoColor.dark, { darkMode: true })}
${generateColorVariable('warning', warningColor.dark, { darkMode: true })}
@@ -161,16 +177,7 @@ export async function CustomizationRootLayout(props: {
}
`}</style>
</head>
<body
className={tcls(
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'bg-tint-base',
'theme-muted:bg-tint-subtle',
'theme-gradient:bg-gradient-primary',
'theme-gradient-tint:bg-gradient-tint'
)}
>
<body className="site-background">
<IconsProvider
assetsURL={GITBOOK_ICONS_URL}
assetsURLToken={GITBOOK_ICONS_TOKEN}
@@ -20,8 +20,7 @@
}
}
@utility hide-scroll {
/* hide scroll bar but still scroll */
@utility no-scrollbar {
&::-webkit-scrollbar {
display: none;
}
@@ -29,6 +28,22 @@
-ms-overflow-style: none;
}
@utility hide-scrollbar {
/* Hide scrollbar by default */
scrollbar-color: transparent transparent;
transition: scrollbar-color 0.2s ease-in-out;
&:hover {
/* Show scrollbar on hover */
scrollbar-color: unset;
}
@media (prefers-contrast: more) {
/* Always show scrollbar in high contrast mode */
scrollbar-color: unset;
}
}
@utility linear-mask-gradient {
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 96px, rgba(0, 0, 0, 0));
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 96px, rgba(0, 0, 0, 0));
@@ -46,7 +61,6 @@
@utility gutter-stable {
scrollbar-gutter: stable;
scrollbar-gutter: stable;
}
@utility triangle {
@@ -143,45 +157,13 @@
@apply bg-linear-to-bl from-tint-4 to-tint-base to-60% bg-fixed;
}
@utility scroll-nojump {
/**
* Utility to prevent horizontal jumps when the vertical scrollbar appears
* It should be used on main containers of the layout, while preserving horizontal delimiters.
* It basically adds a margin-left with the width of the scrollbar, and a width that compensates for it; when a scrollbar is displayed.
* Inspired by https://aykevl.nl/2014/09/fix-jumping-scrollbar, but adapted to work on child elements of "html"
*/
width: 100%;
@media screen and (min-width: 1536px) {
margin-left: var(--scrollbar-width);
margin-right: 0;
width: calc(100% - var(--scrollbar-width));
body:has(.page-full-width) & {
margin-left: 0;
width: 100%;
}
}
@utility site-background {
@apply [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle;
@apply bg-tint-base;
@apply theme-muted:bg-tint-subtle;
/**
* Utility to prevent horizontal jumps when the vertical scrollbar appears
* It should be used on main containers of the layout, while preserving horizontal delimiters.
* It basically adds a margin-left with the width of the scrollbar, and a width that compensates for it; when a scrollbar is displayed.
* Inspired by https://aykevl.nl/2014/09/fix-jumping-scrollbar, but adapted to work on child elements of "html"
*/
width: 100%;
@media screen and (min-width: 1536px) {
margin-left: var(--scrollbar-width);
margin-right: 0;
width: calc(100% - var(--scrollbar-width));
}
}
@utility page-full-width {
@media screen and (min-width: 1536px) {
body:has(&) .scroll-nojump {
margin-left: 0;
width: 100%;
}
}
@apply theme-gradient:bg-gradient-primary;
@apply theme-gradient-tint:bg-gradient-tint;
}
@utility elevate-link {
@@ -211,14 +193,59 @@
@layer base {
:root {
--scrollbar-width: calc(100vw - 100%);
@apply leading-relaxed;
interpolate-size: allow-keywords; /* Opt-in for modern browsers to interpolate "auto" values in transitions/animations. */
}
/* Modern browsers with `scrollbar-*` support */
@supports (scrollbar-color: auto) {
* {
scrollbar-color: theme("colors.tint.7") transparent;
scrollbar-width: thin;
}
@media (prefers-contrast: more) {
* {
scrollbar-color: theme("colors.tint.11") transparent;
scrollbar-width: auto;
}
}
}
/* Otherwise, use `::-webkit-scrollbar-*` pseudo-elements */
@supports selector(::-webkit-scrollbar) {
::-webkit-scrollbar {
background: transparent;
max-width: 8px;
}
::-webkit-scrollbar-thumb {
background: theme("colors.tint.7");
border-radius: 8px;
transition: background 0.2s ease-in-out;
}
::-webkit-scrollbar-thumb:hover {
background: theme("colors.tint.8");
}
@media (prefers-contrast: more) {
::-webkit-scrollbar-thumb {
background: theme("colors.tint.11");
}
::-webkit-scrollbar-thumb:hover {
background: theme("colors.tint.12");
}
}
}
body {
@apply text-tint-strong antialiased;
}
html {
@apply gutter-stable;
scrollbar-width: auto;
}
h1 {
@apply tracking-[-0.025em] text-tint-strong text-balance;
@@ -249,22 +276,6 @@
@apply subpixel-antialiased;
}
/* Light mode */
::-webkit-scrollbar {
@apply bg-tint-subtle;
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
@apply bg-tint-7;
border-radius: 8px;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-tint-8;
}
.flip-heading-hash {
@apply [h1,h2,h3,h4]:content-start;
@apply [h1,h2,h3,h4]:auto-cols-[auto_1fr];
@@ -357,6 +368,11 @@ html.dark.code-monochrome {
--shiki-token-function: rgb(var(--primary-10));
}
html.font-Lato {
/* Lato's default ligatures impact readability, so we turn them off */
font-variant-ligatures: no-common-ligatures;
}
/* Code blocks */
.highlight-line {
@apply grid grid-cols-subgrid col-span-2 relative ring-1 ring-transparent hover:ring-tint-hover hover:z-1 rounded-sm;
@@ -23,13 +23,14 @@ interface SearchContainerProps {
isMultiVariants: boolean;
style: CustomizationSearchStyle;
className?: string;
viewport?: 'desktop' | 'mobile';
}
/**
* Client component to render the search input and results.
*/
export function SearchContainer(props: SearchContainerProps) {
const { siteSpaceId, spaceTitle, isMultiVariants, style, className } = props;
const { siteSpaceId, spaceTitle, isMultiVariants, style, className, viewport } = props;
const { assistants } = useAI();
@@ -55,7 +56,7 @@ export function SearchContainer(props: SearchContainerProps) {
initialRef.current = true;
// For simplicity we're only triggering the first assistant
assistants[0].open(state?.ask ?? undefined);
assistants[0]?.open(state?.ask ?? undefined);
}, [state?.ask, assistants.length, assistants[0]?.open]);
const onClose = React.useCallback(
@@ -88,6 +89,19 @@ export function SearchContainer(props: SearchContainerProps) {
}
);
useHotkeys(
'mod+i',
(e) => {
e.preventDefault();
if (assistants) {
assistants[0]?.open();
}
},
{
enableOnFormTags: true,
}
);
const onOpen = React.useCallback(() => {
if (state?.open) {
return;
@@ -145,6 +159,8 @@ export function SearchContainer(props: SearchContainerProps) {
const showAsk = withSearchAI && normalizedAsk; // withSearchAI && normalizedAsk;
const visible = viewport === 'desktop' ? !isMobile : viewport === 'mobile' ? isMobile : true;
return (
<SearchAskProvider value={searchAsk}>
<Popover
@@ -168,7 +184,7 @@ export function SearchContainer(props: SearchContainerProps) {
) : null
}
rootProps={{
open: state?.open ?? false,
open: visible && (state?.open ?? false),
onOpenChange: (open) => {
open ? onOpen() : onClose();
},
@@ -178,7 +194,7 @@ export function SearchContainer(props: SearchContainerProps) {
onOpenAutoFocus: (event) => event.preventDefault(),
align: 'start',
className:
'bg-tint-base has-[.empty]:hidden scroll-py-2 w-128 p-2 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
'bg-tint-base has-[.empty]:hidden gutter-stable scroll-py-2 w-128 p-2 pr-1 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)) {
@@ -211,10 +227,11 @@ export function SearchContainer(props: SearchContainerProps) {
</Popover>
{assistants
.filter((assistant) => assistant.ui === true)
.map((assistant) => (
.map((assistant, index) => (
<AIChatButton
key={assistant.id}
assistant={assistant}
withShortcut={index === 0}
showLabel={
assistants.filter((assistant) => assistant.ui === true).length === 1 &&
style === CustomizationSearchStyle.Prominent
@@ -46,7 +46,7 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
}, [isOpen, value]);
return (
<div className="relative flex size-9 grow">
<div className={tcls('relative flex size-9 grow', className)}>
{/* 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
@@ -62,8 +62,7 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
'theme-bold:border-header-link/3 has-[input:focus-visible]:theme-bold:border-header-link/5 has-[input:focus-visible]:theme-bold:bg-header-link/3 has-[input:focus-visible]:theme-bold:ring-header-link/5',
'theme-bold:before:absolute theme-bold:before:inset-0 theme-bold:before:bg-header-background/7 theme-bold:before:backdrop-blur-xl ', // Special overlay to make the transparent colors of theme-bold visible.
'relative z-30 shrink grow justify-start max-md:absolute max-md:right-0',
isOpen ? 'max-md:w-56' : 'max-md:w-[38px]',
className
isOpen ? 'max-md:w-56' : 'max-md:w-[38px]'
)}
>
{value && isOpen ? (
@@ -296,7 +296,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
key={item.id}
question={item.question}
active={index === cursor}
assistant={assistants[0]}
assistant={assistants[0]!}
recommended
/>
);

Some files were not shown because too many files have changed in this diff Show More