Compare commits

..

48 Commits

Author SHA1 Message Date
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
166 changed files with 3740 additions and 1426 deletions
+442 -66
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.140.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);
+16
View File
@@ -1,5 +1,21 @@
# @gitbook/expr
## 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
+8 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@gitbook/expr",
"description": "Safely evaluate & parse user-defined GitBook expressions.",
"version": "1.0.0",
"version": "1.1.1",
"type": "module",
"exports": {
".": {
@@ -12,23 +12,26 @@
},
"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",
+56
View File
@@ -1,5 +1,61 @@
# gitbook
## 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
@@ -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"/,
+8 -7
View File
@@ -1,12 +1,12 @@
{
"name": "gitbook",
"version": "0.16.0",
"version": "0.17.2",
"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",
@@ -70,8 +71,7 @@
"url-join": "^5.0.0",
"usehooks-ts": "^3.1.0",
"warn-once": "^0.1.1",
"zustand": "^5.0.3",
"bidc": "catalog:"
"zustand": "^5.0.3"
},
"devDependencies": {
"@argos-ci/playwright": "^5.0.9",
@@ -104,7 +104,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",
@@ -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',
},
}
);
@@ -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]);
}
};
@@ -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
)}
/>
);
}
@@ -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,7 +47,9 @@ 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. */
@@ -263,6 +266,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;
}
@@ -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,32 @@
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 { ChatGPTIcon } from '@/components/PageActions/assets/ChatGPTIcon';
import { ClaudeIcon } from '@/components/PageActions/assets/ClaudeIcon';
import { MarkdownIcon } from '@/components/PageActions/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 { create } from 'zustand';
type AIActionType = 'button' | 'dropdown-menu-item';
type PageActionType = 'button' | 'dropdown-menu-item';
/**
* Opens our AI Docs Assistant.
*/
export function OpenAIAssistant(props: { assistant: Assistant; type: AIActionType }) {
export function OpenAIAssistant(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}
@@ -90,7 +91,7 @@ const markdownCache = new QuickLRU<string, string>({ maxSize: 10 });
*/
export function CopyMarkdown(props: {
markdownPageUrl: string;
type: AIActionType;
type: PageActionType;
isDefaultAction?: boolean;
}) {
const { markdownPageUrl, type, isDefaultAction } = props;
@@ -130,7 +131,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,12 +146,12 @@ export function CopyMarkdown(props: {
/**
* Redirects to the markdown version of the page.
*/
export function ViewAsMarkdown(props: { markdownPageUrl: string; type: AIActionType }) {
export function ViewAsMarkdown(props: { markdownPageUrl: string; type: PageActionType }) {
const { markdownPageUrl, type } = props;
const language = useLanguage();
return (
<AIActionWrapper
<PageActionWrapper
type={type}
icon={<MarkdownIcon className="size-4 fill-current" />}
label={tString(language, 'view_page_markdown')}
@@ -166,7 +167,7 @@ export function ViewAsMarkdown(props: { markdownPageUrl: string; type: AIActionT
export function OpenInLLM(props: {
provider: 'chatgpt' | 'claude';
url: string;
type: AIActionType;
type: PageActionType;
}) {
const { provider, url, type } = props;
const language = useLanguage();
@@ -174,7 +175,7 @@ export function OpenInLLM(props: {
const providerLabel = provider === 'chatgpt' ? 'ChatGPT' : 'Claude';
return (
<AIActionWrapper
<PageActionWrapper
type={type}
icon={
provider === 'chatgpt' ? (
@@ -191,11 +192,48 @@ export function OpenInLLM(props: {
);
}
export function GitEditLink(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}
/>
);
}
export function ViewAsPDF(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"
/>
);
}
/**
* 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 +243,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 +272,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 +284,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 +305,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>
@@ -2,38 +2,50 @@
import {
CopyMarkdown,
GitEditLink,
OpenAIAssistant,
OpenInLLM,
ViewAsMarkdown,
} from '@/components/AIActions/AIActions';
ViewAsPDF,
} from '@/components/PageActions/PageActions';
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 type { GitSyncState, SiteCustomizationSettings } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { useRef } from 'react';
import { useAI } from '../AI';
interface AIActionsDropdownProps {
interface PageActionsDropdownProps {
markdownPageUrl: string;
className?: string;
actions: SiteCustomizationSettings['pageActions'];
editOnGit?: {
provider: GitSyncState['installationProvider'];
url: string;
};
pdfUrl?: string;
}
/**
* Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM).
*/
export function AIActionsDropdown(props: AIActionsDropdownProps) {
export function PageActionsDropdown(props: PageActionsDropdownProps) {
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 ? (
const defaultActions = [assistants.length > 0, props.actions.markdown, props.editOnGit].filter(
Boolean
);
const dropdownActions = [props.actions.externalAI, props.pdfUrl].filter(Boolean);
return [...defaultActions, ...dropdownActions].length > 0 ? (
<ButtonGroup ref={ref} className={props.className}>
<DefaultAction {...props} />
{props.actions.markdown || props.actions.externalAI ? (
{defaultActions.length > 0 ? <DefaultAction {...props} /> : null}
{dropdownActions.length > 0 ? (
<DropdownMenu
align="end"
className="!min-w-60 max-w-max"
@@ -45,15 +57,18 @@ export function AIActionsDropdown(props: AIActionsDropdownProps) {
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
/>
}
label={tString(language, 'more')}
iconOnly
label={tString(
language,
defaultActions.some(Boolean) ? 'more' : 'actions'
)}
iconOnly={defaultActions.some(Boolean)}
size="xsmall"
variant="secondary"
className="bg-tint-base text-sm"
/>
}
>
<AIActionsDropdownMenuContent {...props} />
<PageActionsDropdownMenuContent {...props} />
</DropdownMenu>
) : null}
</ButtonGroup>
@@ -63,7 +78,7 @@ export function AIActionsDropdown(props: AIActionsDropdownProps) {
/**
* The content of the dropdown menu.
*/
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
function PageActionsDropdownMenuContent(props: PageActionsDropdownProps) {
const { markdownPageUrl, actions } = props;
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
@@ -98,6 +113,22 @@ function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
<OpenInLLM provider="claude" url={markdownPageUrl} type="dropdown-menu-item" />
</>
) : null}
{props.editOnGit || props.pdfUrl ? (
<>
<DropdownMenuSeparator className="first:hidden" />
{props.editOnGit ? (
<GitEditLink
type="dropdown-menu-item"
provider={props.editOnGit.provider}
url={props.editOnGit.url}
/>
) : null}
{props.pdfUrl ? (
<ViewAsPDF url={props.pdfUrl} type="dropdown-menu-item" />
) : null}
</>
) : null}
</>
);
}
@@ -105,31 +136,34 @@ function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
/**
* A default action shown as a quick-access button beside the dropdown menu
*/
function DefaultAction(props: AIActionsDropdownProps) {
function DefaultAction(props: PageActionsDropdownProps) {
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" />;
const assistant = assistants[0];
if (assistant) {
return <OpenAIAssistant assistant={assistant} type="button" />;
}
if (props.editOnGit) {
return (
<GitEditLink
type="button"
provider={props.editOnGit.provider}
url={props.editOnGit.url}
/>
);
}
if (actions.markdown) {
return (
<CopyMarkdown
isDefaultAction={!assistants.length}
isDefaultAction={!assistant}
markdownPageUrl={markdownPageUrl}
type="button"
/>
);
}
if (actions.externalAI) {
return (
<>
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="button" />
</>
);
}
}
@@ -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 { 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,30 @@ export async function PageHeader(props: {
>
{page.layout.tableOfContents ? (
// Show page actions if *any* of the actions are enabled
<AIActionsDropdown
<PageActionsDropdown
markdownPageUrl={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`}
actions={pageActions}
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
}
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
/>
);
@@ -71,8 +71,7 @@ export interface AskAnswerResult {
export async function searchAllSiteContent(query: string): Promise<OrderedComputedResult[]> {
return traceErrorOnly('Search.searchAllSiteContent', async () => {
const context = await getServerActionBaseContext();
return await searchSiteContent(context, {
return searchSiteContent(context, {
query,
scope: { mode: 'all' },
});
@@ -124,8 +123,7 @@ export async function streamAskQuestion({
},
scope: {
mode: 'default',
// Include the current site space regardless.
includedSiteSpaces: [context.siteSpace.id],
currentSiteSpace: context.siteSpace.id,
},
},
{ format: 'document' }
@@ -270,7 +268,7 @@ async function searchSiteContent(
return (
await Promise.all(
searchResults.map(async (spaceItem) => {
searchResults.map((spaceItem) => {
const found = findSiteSpaceBy(
structure,
(siteSpace) => siteSpace.space.id === spaceItem.id
@@ -13,7 +13,7 @@ import { buildVersion } from '@/lib/build';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL, GITBOOK_ICONS_URL } from '@/lib/env';
import { getResizedImageURL } from '@/lib/images';
import { isSiteIndexable } from '@/lib/seo';
import { AIChatProvider, AIContextProvider } from '../AI';
import { AIContextProvider } from '../AI';
import { RocketLoaderDetector } from './RocketLoaderDetector';
import { SiteLayoutClientContexts } from './SiteLayoutClientContexts';
@@ -58,15 +58,13 @@ export async function SiteLayout(props: {
aiMode={customization.ai?.mode}
trademark={customization.trademark.enabled}
>
<AIChatProvider>
<SpaceLayout
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
>
{children}
</SpaceLayout>
</AIChatProvider>
<SpaceLayout
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
>
{children}
</SpaceLayout>
</AIContextProvider>
{scripts.length > 0 ? (
@@ -1,6 +1,10 @@
import type { GitBookSiteContext } from '@/lib/context';
import { getPageDocument } from '@/lib/data';
import { CustomizationHeaderPreset, CustomizationThemeMode } from '@gitbook/api';
import {
CustomizationHeaderPreset,
CustomizationThemeMode,
SiteInsightsDisplayContext,
} from '@gitbook/api';
import type { Metadata, Viewport } from 'next';
import { notFound, redirect } from 'next/navigation';
import React from 'react';
@@ -65,6 +69,7 @@ export async function SitePage(props: SitePageProps) {
ancestors={ancestors}
document={document}
withPageFeedback={withPageFeedback}
insightsDisplayContext={SiteInsightsDisplayContext.Site}
/>
</div>
<React.Suspense fallback={null}>
@@ -3,6 +3,7 @@
import { TrackPageViewEvent } from '@/components/Insights';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import { useSpaceBasePath } from '../SpaceLayout/SpaceLayoutContext';
@@ -46,7 +47,7 @@ export function SitePageNotFound() {
</div>
{/* Track the page not found as a page view */}
<TrackPageViewEvent />
<TrackPageViewEvent displayContext={SiteInsightsDisplayContext.Site} />
</div>
</CurrentPageProvider>
);
@@ -38,7 +38,7 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
>
<TOCScrollContainer
style={{ maxHeight: `${MAX_ITEMS * 3 + 2}rem` }}
className="gutter-stable overflow-y-auto px-2 pb-6"
className="overflow-y-auto px-2 pb-4"
>
{sectionsAndGroups.map((item) => {
if (item.object === 'site-section-group') {
@@ -110,7 +110,7 @@ export function SiteSectionListItem(props: {
className={tcls(
'flex size-8 shrink-0 items-center justify-center rounded-md straight-corners:rounded-none bg-tint-subtle text-lg text-tint leading-none shadow-tint shadow-xs ring-1 ring-tint-subtle transition-transform group-hover/section-link:scale-110 group-hover/section-link:ring-tint-hover group-active/section-link:scale-90 group-active/section-link:shadow-none contrast-more:text-tint-strong dark:shadow-none',
isActive
? 'bg-primary tint:bg-primary-solid text-primary-subtle tint:text-contrast-primary-solid shadow-md shadow-primary ring-primary group-hover/section-link:ring-primary-hover, contrast-more:text-primary contrast-more:ring-2 contrast-more:ring-primary'
? 'bg-primary tint:bg-primary-original text-primary-subtle tint:text-contrast-primary-original shadow-md shadow-primary ring-primary group-hover/section-link:ring-primary-hover, contrast-more:text-primary contrast-more:ring-2 contrast-more:ring-primary'
: null
)}
>
@@ -30,9 +30,7 @@ export function encodeClientSiteSections(context: GitBookSiteContext, sections:
for (const item of list) {
switch (item.object) {
case 'site-section-group': {
const sections = item.sections
.filter((section) => shouldIncludeSection(context, section))
.map((section) => encodeSection(context, section));
const sections = item.sections.map((section) => encodeSection(context, section));
// Skip empty groups
if (sections.length === 0) {
@@ -74,38 +72,13 @@ function encodeSection(context: GitBookSiteContext, section: SiteSection) {
};
}
/**
* Test if a section should be included in the list of sections.
*/
function shouldIncludeSection(context: GitBookSiteContext, section: SiteSection) {
if (context.site.id !== 'site_JOVzv') {
return true;
}
// Testing for a new mode of navigation where the multi-variants section are hidden
// if they do not include an equivalent of the current site space.
// TODO: replace with a proper flag on the section
const withNavigateOnlyIfEquivalent = section.id === 'sitesc_4jvEm';
if (!withNavigateOnlyIfEquivalent) {
return true;
}
const { siteSpace: currentSiteSpace } = context;
if (section.siteSpaces.length === 1) {
return true;
}
return section.siteSpaces.some((siteSpace) =>
areSiteSpacesEquivalent(siteSpace, currentSiteSpace)
);
}
/**
* Find the best default site space to navigate to for a givent section:
* 1. If we are on the default, continue on the default.
* 2. If a site space has the same path as the current one, return it.
* 3. Otherwise, return the default one.
* 2. If there are site spaces with the same language as the current, filter by language.
* 3. If a site space has the same path as the current one, return it.
* 4. Otherwise, return the default first language match.
* 5. Otherwise, return the default one.
*/
function findBestTargetURL(context: GitBookSiteContext, section: SiteSection) {
const { siteSpace: currentSiteSpace } = context;
@@ -114,9 +87,15 @@ function findBestTargetURL(context: GitBookSiteContext, section: SiteSection) {
return getSectionURL(context, section);
}
const bestMatch = section.siteSpaces.find((siteSpace) =>
areSiteSpacesEquivalent(siteSpace, currentSiteSpace)
);
const possibleMatches =
section.siteSpaces.filter((siteSpace) =>
areSiteSpacesSameLanguage(siteSpace, currentSiteSpace)
) ?? section.siteSpaces;
const bestMatch =
possibleMatches.find((siteSpace) => areSiteSpacesEquivalent(siteSpace, currentSiteSpace)) ??
possibleMatches[0];
if (bestMatch) {
return getSiteSpaceURL(context, bestMatch);
}
@@ -130,3 +109,7 @@ function findBestTargetURL(context: GitBookSiteContext, section: SiteSection) {
function areSiteSpacesEquivalent(siteSpace1: SiteSpace, siteSpace2: SiteSpace) {
return siteSpace1.path === siteSpace2.path;
}
function areSiteSpacesSameLanguage(siteSpace1: SiteSpace, siteSpace2: SiteSpace) {
return siteSpace1.space.language === siteSpace2.space.language;
}
@@ -4,7 +4,7 @@ import {
CustomizationHeaderPreset,
CustomizationSearchStyle,
} from '@gitbook/api';
import React from 'react';
import type React from 'react';
import { Footer } from '@/components/Footer';
import { Header, HeaderLogo } from '@/components/Header';
@@ -12,21 +12,22 @@ import { TableOfContents } from '@/components/TableOfContents';
import { CONTAINER_STYLE } from '@/components/layout';
import { tcls } from '@/lib/tailwind';
import { getSpaceLanguage } from '@/intl/server';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { GITBOOK_APP_URL } from '@/lib/env';
import { AIChatProvider } from '../AI';
import type { RenderAIMessageOptions } from '../AI';
import { AIChat } from '../AIChat';
import { AdaptiveVisitorContextProvider } from '../Adaptive';
import { Announcement } from '../Announcement';
import { SpacesDropdown } from '../Header/SpacesDropdown';
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
import { InsightsProvider } from '../Insights';
import { SearchContainer } from '../Search';
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
import { CurrentContentProvider } from '../hooks';
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
/**
* Render the entire layout of the space (header, table of contents, footer).
*/
export function SpaceLayout(props: {
type SpaceLayoutProps = {
context: GitBookSiteContext;
/** Whether to enable tracking of events into site insights. */
@@ -35,39 +36,21 @@ export function SpaceLayout(props: {
/** The visitor auth claims. */
visitorAuthClaims: VisitorAuthClaims;
/** The options for rendering AI messages. */
aiChatRenderMessageOptions?: RenderAIMessageOptions;
/** The children of the layout. */
children: React.ReactNode;
}) {
const { context, withTracking, visitorAuthClaims, children } = props;
const { siteSpace, customization, sections, siteSpaces } = context;
};
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
/**
* Provide all contexts for a space.
*/
export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
const { context, withTracking, visitorAuthClaims, aiChatRenderMessageOptions, children } =
props;
const withSections = Boolean(sections && sections.list.length > 1);
const isMultiVariants = Boolean(siteSpaces.length > 1);
const withFooter =
customization.themes.toggeable ||
customization.footer.copyright ||
customization.footer.logo ||
customization.footer.groups?.length;
const search = (
<div className="flex grow items-center gap-2">
<React.Suspense fallback={null}>
<SearchContainer
style={
customization.header.preset === CustomizationHeaderPreset.None
? CustomizationSearchStyle.Subtle
: customization.styling.search
}
isMultiVariants={siteSpaces.length > 1}
spaceTitle={siteSpace.title}
siteSpaceId={siteSpace.id}
/>
</React.Suspense>
</div>
);
const { customization } = context;
const eventUrl = new URL(
context.linker.toAbsoluteURL(context.linker.toPathInSite('/~gitbook/__evt'))
@@ -75,99 +58,158 @@ export function SpaceLayout(props: {
eventUrl.searchParams.set('o', context.organizationId);
eventUrl.searchParams.set('s', context.site.id);
const getVisitorClaimsUrl = context.linker.toAbsoluteURL(
context.linker.toPathInSite('/~gitbook/visitor')
);
return (
<SpaceLayoutContextProvider basePath={context.linker.toPathInSpace('')}>
<CurrentContentProvider
organizationId={context.organizationId}
siteId={context.site.id}
siteSectionId={context.sections?.current?.id ?? null}
siteSpaceId={context.siteSpace.id}
siteShareKey={context.shareKey ?? null}
spaceId={context.space.id}
revisionId={context.revisionId}
visitorAuthClaims={visitorAuthClaims}
<AdaptiveVisitorContextProvider
contextId={context.contextId}
visitorClaimsURL={getVisitorClaimsUrl}
>
<InsightsProvider
enabled={withTracking}
appURL={GITBOOK_APP_URL}
eventUrl={eventUrl.toString()}
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
<CurrentContentProvider
organizationId={context.organizationId}
siteId={context.site.id}
siteSectionId={context.sections?.current?.id ?? null}
siteSpaceId={context.siteSpace.id}
siteShareKey={context.shareKey ?? null}
spaceId={context.space.id}
revisionId={context.revisionId}
visitorAuthClaims={visitorAuthClaims}
>
<Announcement context={context} />
<Header withTopHeader={withTopHeader} context={context} search={search} />
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
<AIChat trademark={customization.trademark.enabled} />
) : null}
<div className="scroll-nojump">
<div className="motion-safe:transition-all motion-safe:duration-300 lg:chat-open:mr-80 xl:chat-open:mr-96">
<div
className={tcls(
'flex',
'flex-col',
'lg:flex-row',
CONTAINER_STYLE,
'site-width-wide:max-w-full',
// Ensure the footer is display below the viewport even if the content is not enough
withFooter && 'min-h-[calc(100vh-64px)]',
withTopHeader ? null : 'lg:min-h-screen'
)}
>
<TableOfContents
context={context}
header={
withTopHeader ? null : (
<div
className={tcls(
'hidden',
'pr-4',
'md:flex',
'grow-0',
'flex-wrap',
'dark:shadow-light/1',
'text-base/tight'
)}
>
<HeaderLogo context={context} />
</div>
)
}
innerHeader={
// displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC.
<>
{!withTopHeader && search}
{!withTopHeader && withSections && sections && (
<SiteSectionList
className={tcls('hidden', 'lg:block')}
sections={encodeClientSiteSections(
context,
sections
)}
/>
)}
{isMultiVariants && !sections && (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
className={tcls(
'w-full',
'page-no-toc:hidden',
'page-no-toc:site-header-none:flex'
)}
/>
)}
</>
}
/>
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
</div>
</div>
</div>
{withFooter ? <Footer context={context} /> : null}
</InsightsProvider>
</CurrentContentProvider>
<InsightsProvider
enabled={withTracking}
appURL={GITBOOK_APP_URL}
eventUrl={eventUrl.toString()}
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
>
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
{children}
</AIChatProvider>
</InsightsProvider>
</CurrentContentProvider>
</AdaptiveVisitorContextProvider>
</SpaceLayoutContextProvider>
);
}
/**
* Render the entire layout of the space (header, table of contents, footer).
*/
export function SpaceLayout(props: SpaceLayoutProps) {
const { context, children } = props;
const { siteSpace, customization, sections, siteSpaces } = context;
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
const withSections = Boolean(sections && sections.list.length > 1);
const currentLanguage = getSpaceLanguage(context);
const withVariants: 'generic' | 'translations' | undefined =
siteSpaces.length > 1
? siteSpaces.some(
(space) => space.space.language && space.space.language !== currentLanguage.locale
)
? 'translations'
: 'generic'
: undefined;
const withFooter =
customization.themes.toggeable ||
customization.footer.copyright ||
customization.footer.logo ||
customization.footer.groups?.length;
return (
<SpaceLayoutServerContext {...props}>
<Announcement context={context} />
<Header withTopHeader={withTopHeader} withVariants={withVariants} context={context} />
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
<AIChat trademark={customization.trademark.enabled} />
) : null}
<div className="motion-safe:transition-all motion-safe:duration-300 lg:chat-open:mr-80 xl:chat-open:mr-96">
<div
className={tcls(
'flex',
'flex-col',
'lg:flex-row',
CONTAINER_STYLE,
'site-width-wide:max-w-full',
// Ensure the footer is display below the viewport even if the content is not enough
withFooter && [
'site-header:min-h-[calc(100vh-64px)]',
'site-header-sections:min-h-[calc(100vh-108px)]',
],
withTopHeader ? null : 'lg:min-h-screen'
)}
>
<TableOfContents
context={context}
header={
withTopHeader ? null : (
<div
className={tcls(
'hidden',
'pr-4',
'lg:flex',
'grow-0',
'dark:shadow-light/1',
'text-base/tight',
'items-center'
)}
>
<HeaderLogo context={context} />
{withVariants === 'translations' ? (
<TranslationsDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden"
/>
) : null}
</div>
)
}
innerHeader={
// displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC.
<>
{!withTopHeader && (
<div className="flex gap-2">
<SearchContainer
style={CustomizationSearchStyle.Subtle}
isMultiVariants={siteSpaces.length > 1}
spaceTitle={siteSpace.title}
siteSpaceId={siteSpace.id}
className="max-lg:hidden"
viewport="desktop"
/>
</div>
)}
{!withTopHeader && withSections && sections && (
<SiteSectionList
className={tcls('hidden', 'lg:block')}
sections={encodeClientSiteSections(context, sections)}
/>
)}
{withVariants === 'generic' && (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
className="w-full px-3 py-2"
/>
)}
</>
}
/>
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
</div>
</div>
{withFooter ? <Footer context={context} /> : null}
</SpaceLayoutServerContext>
);
}

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