mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-15 15:15:55 +00:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38226b9413 | |||
| b932e4e2e9 | |||
| ca4a3e8c9a | |||
| b498521196 | |||
| 8a8cf2025d | |||
| ed7d47d9b8 | |||
| b5494cc04b | |||
| 229f2ba9d5 | |||
| 6da36558f6 | |||
| ca71c1fad6 | |||
| 262afa357b | |||
| 7375d3c597 | |||
| 3548fa6dff | |||
| cb73040e0f | |||
| 872d36b64f | |||
| e1ff17e655 | |||
| f3010bd28a | |||
| 8ff1e3b619 | |||
| d7596bf454 | |||
| aea5eb10ae | |||
| 1165a81cf5 | |||
| 61d1a0192e | |||
| f9a2977621 | |||
| 24f601d594 | |||
| 7b34537bbe | |||
| 145f385fdb | |||
| 360aa1c6d6 | |||
| ccc0975240 | |||
| 791135040a | |||
| 12c9d765ff | |||
| 45207288a1 | |||
| 177afa5828 | |||
| d51b79edbb | |||
| a2720ac49d | |||
| 5f6853d812 | |||
| 17dd382dc3 | |||
| 98e42cfe79 | |||
| c890e01004 | |||
| 659be551bb | |||
| 78a632b7fe | |||
| 4927e964b7 | |||
| 262a9b198b | |||
| 4f3588240c | |||
| abbae3ec4d | |||
| 1b8609ac60 | |||
| 193d591e9f | |||
| 1edc5d622a | |||
| 61b8507996 | |||
| 6f368b5cf3 | |||
| 9201e2cf52 | |||
| 2e0d706d43 | |||
| 7fefe4919c | |||
| 729921f338 | |||
| 1839ea2678 | |||
| f08dd29364 | |||
| ca2dfa3065 | |||
| 10ded437f0 | |||
| d1fdc13441 | |||
| a8fca0e033 |
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Simplify AI Chat
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix robots.txt preventing favicon from being indexed.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Show a toolbar for authenticated users with access to the current site.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Scope search across sections and variants
|
||||||
@@ -182,9 +182,9 @@ jobs:
|
|||||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
ARGOS_BUILD_NAME: 'customers-v2'
|
ARGOS_BUILD_NAME: 'customers-v2'
|
||||||
pagespeed-testing-v2:
|
browserless-testing-v2-vercel:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: PageSpeed Testing v1
|
name: Browserless Testing v2 (Vercel)
|
||||||
needs: deploy-v2-vercel
|
needs: deploy-v2-vercel
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -195,8 +195,26 @@ jobs:
|
|||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- name: Run pagespeed tests
|
- name: Run tests
|
||||||
run: bun ./packages/gitbook/tests/pagespeed-testing.ts
|
run: cd ./packages/gitbook && bun e2e-browserless
|
||||||
env:
|
env:
|
||||||
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
|
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
|
||||||
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
|
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||||
|
# browserless-testing-v2-cloudflare:
|
||||||
|
# runs-on: ubuntu-latest
|
||||||
|
# name: Browserless Testing v2 (Cloudflare)
|
||||||
|
# needs: deploy-v2-cloudflare
|
||||||
|
# steps:
|
||||||
|
# - name: Checkout
|
||||||
|
# uses: actions/checkout@v4
|
||||||
|
# - name: Setup Bun
|
||||||
|
# uses: ./.github/composite/setup-bun
|
||||||
|
# - name: Install dependencies
|
||||||
|
# run: bun install --frozen-lockfile
|
||||||
|
# env:
|
||||||
|
# PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
|
# - name: Run tests
|
||||||
|
# run: cd ./packages/gitbook && bun e2e-browserless
|
||||||
|
# env:
|
||||||
|
# BASE_URL: ${{needs.deploy-v2-cloudflare.outputs.deployment-url}}
|
||||||
|
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||||
+1
-1
@@ -34,7 +34,7 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": ["packages/*"],
|
"packages": ["packages/*"],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@gitbook/api": "^0.136.0",
|
"@gitbook/api": "^0.141.0",
|
||||||
"bidc": "^0.0.2"
|
"bidc": "^0.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
|
|||||||
@@ -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.
|
* Get the tags for a computed content source.
|
||||||
*/
|
*/
|
||||||
@@ -182,7 +171,7 @@ export function getComputedContentSourceCacheTags(
|
|||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Do not throw for unknown dependency types
|
// 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;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -198,9 +187,8 @@ export function getComputedContentSourceCacheTags(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// We invalidate the computed content when a new version of the integration is deployed.
|
// We invalidate the computed content when a new version of the integration is deployed.
|
||||||
|
|
||||||
if (source.type.startsWith('integration:')) {
|
if (source.type.startsWith('integration:')) {
|
||||||
const integration = source.type.split(':')[1];
|
const integration = source.type.split(':')[1]!;
|
||||||
tags.push(
|
tags.push(
|
||||||
getCacheTag({
|
getCacheTag({
|
||||||
tag: 'integration',
|
tag: 'integration',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# @gitbook/colors
|
# @gitbook/colors
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 17dd382: Add `original` background color step
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 193d591: Fix return type for `colorContrast`
|
||||||
|
|
||||||
## 0.3.3
|
## 0.3.3
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.3.3",
|
"version": "0.4.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.5.3"
|
"typescript": "^5.5.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type RGBColor = [number, number, number];
|
|||||||
type OKLABColor = { L: number; A: number; B: number };
|
type OKLABColor = { L: number; A: number; B: number };
|
||||||
type OKLCHColor = { L: number; C: number; H: 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 {
|
export enum ColorCategory {
|
||||||
backgrounds = 'backgrounds',
|
backgrounds = 'backgrounds',
|
||||||
@@ -19,7 +19,7 @@ export enum ColorCategory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ColorSubScale = {
|
type ColorSubScale = {
|
||||||
[key: string]: number;
|
[key: string]: number | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +56,8 @@ export const scale: Record<ColorCategory, ColorSubScale> = {
|
|||||||
solid: 9,
|
solid: 9,
|
||||||
/** Hovered solid backgrounds */
|
/** Hovered solid backgrounds */
|
||||||
'solid-hover': 10,
|
'solid-hover': 10,
|
||||||
|
/** Original color */
|
||||||
|
original: 'original',
|
||||||
},
|
},
|
||||||
[ColorCategory.text]: {
|
[ColorCategory.text]: {
|
||||||
/** Very low-contrast text
|
/** Very low-contrast text
|
||||||
@@ -211,8 +213,8 @@ export function colorScale(
|
|||||||
const result = [];
|
const result = [];
|
||||||
|
|
||||||
for (let index = 0; index < mapping.length; index++) {
|
for (let index = 0; index < mapping.length; index++) {
|
||||||
const targetL =
|
const step = mapping[index]!;
|
||||||
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
|
const targetL = foregroundColor.L * step + backgroundColor.L * (1 - step);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
index === 8 &&
|
index === 8 &&
|
||||||
@@ -295,7 +297,7 @@ export function rgbArrayToHex(rgb: RGBColor): string {
|
|||||||
|
|
||||||
export function getColor(percentage: number, start: RGBColor, end: RGBColor) {
|
export function getColor(percentage: number, start: RGBColor, end: RGBColor) {
|
||||||
const rgb = end.map((channel, index) => {
|
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);
|
return rgbArrayToHex(rgb as RGBColor);
|
||||||
@@ -392,14 +394,14 @@ export function xyzToLab65(xyz: [number, number, number]): {
|
|||||||
B: number;
|
B: number;
|
||||||
} {
|
} {
|
||||||
const [x, y, z] = xyz.map((v, i) => {
|
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 scaled > 0.008856 ? Math.cbrt(scaled) : 7.787 * scaled + 16 / 116;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
L: 116 * y - 16,
|
L: 116 * y! - 16,
|
||||||
A: 500 * (x - y),
|
A: 500 * (x! - y!),
|
||||||
B: 200 * (y - z),
|
B: 200 * (y! - z!),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +421,10 @@ export function dpsContrast(a: RGBColor, b: RGBColor) {
|
|||||||
return contrast < 7.5 ? 0 : contrast;
|
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 bg = hexToRgbArray(background);
|
||||||
|
|
||||||
const best: { color?: RGBColor; contrast: number } = {
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# @gitbook/embed
|
# @gitbook/embed
|
||||||
|
|
||||||
|
## 0.1.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 6f368b5: Fix embed assistant window width on small screens
|
||||||
|
|
||||||
## 0.1.0
|
## 0.1.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"react": "./dist/react/index.js"
|
"react": "./dist/react/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
"@gitbook/icons": "workspace:",
|
"@gitbook/icons": "workspace:",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
--gitbook-widget-top: 1rem;
|
--gitbook-widget-top: 1rem;
|
||||||
--gitbook-widget-bottom: 1rem;
|
--gitbook-widget-bottom: 1rem;
|
||||||
--gitbook-widget-right: 1rem;
|
--gitbook-widget-right: 1rem;
|
||||||
|
--gitbook-widget-left: 1rem;
|
||||||
|
|
||||||
--gitbook-widget-button-height: 46px;
|
--gitbook-widget-button-height: 46px;
|
||||||
|
|
||||||
@@ -117,7 +118,7 @@
|
|||||||
bottom: var(--gitbook-widget-window-bottom);
|
bottom: var(--gitbook-widget-window-bottom);
|
||||||
right: var(--gitbook-widget-right);
|
right: var(--gitbook-widget-right);
|
||||||
z-index: 9998;
|
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))));
|
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);
|
background-color: var(--gitbook-widget-background-solid);
|
||||||
border: 1px solid var(--gitbook-widget-border-color);
|
border: 1px solid var(--gitbook-widget-border-color);
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
# @gitbook/expr
|
# @gitbook/expr
|
||||||
|
|
||||||
|
## 1.2.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 6da3655: Fix exports in gitbook/expr package.json
|
||||||
|
|
||||||
|
## 1.1.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 3548fa6: Fix eval-estree-expr named import
|
||||||
|
|
||||||
|
## 1.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- e1ff17e: Fix bundling of gitbook/expr package
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 8ff1e3b: Add support for every/some array methods
|
||||||
|
|
||||||
## 1.0.0
|
## 1.0.0
|
||||||
|
|
||||||
### Major Changes
|
### Major Changes
|
||||||
|
|||||||
@@ -1,34 +1,37 @@
|
|||||||
{
|
{
|
||||||
"name": "@gitbook/expr",
|
"name": "@gitbook/expr",
|
||||||
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"development": "./src/index.ts",
|
"import": "./dist/index.js",
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eval-estree-expression": "^2.0.3",
|
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#9cf28d2",
|
||||||
"acorn": "^8.14.0",
|
"acorn": "^8.14.0",
|
||||||
"acorn-loose": "8.4.0",
|
"acorn-loose": "8.4.0",
|
||||||
"acorn-walk": "^8.3.4",
|
"acorn-walk": "^8.3.4",
|
||||||
|
"escodegen": "^2.1.0",
|
||||||
"assert-never": "^1.2.1"
|
"assert-never": "^1.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"bun-types": "^1.1.20",
|
"bun-types": "^1.1.20",
|
||||||
|
"tsdown": "^0.15.0",
|
||||||
"@types/estree": "^1.0.6",
|
"@types/estree": "^1.0.6",
|
||||||
"@babel/types": "^7.26.0",
|
"@babel/types": "^7.26.0",
|
||||||
"@types/json-schema": "^7.0.15"
|
"@types/json-schema": "^7.0.15",
|
||||||
|
"@types/escodegen": "^0.0.10"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --project tsconfig.build.json",
|
"build": "tsdown --project tsconfig.build.json",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"unit": "bun test",
|
"unit": "bun test",
|
||||||
"clean": "rm -rf ./dist"
|
"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.length',
|
||||||
'visitor.claims.hello.at',
|
'visitor.claims.hello.at',
|
||||||
'visitor.claims.hello.includes',
|
'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.length',
|
||||||
'visitor.claims.hello.at',
|
'visitor.claims.hello.at',
|
||||||
'visitor.claims.hello.includes',
|
'visitor.claims.hello.includes',
|
||||||
|
'visitor.claims.hello.some',
|
||||||
|
'visitor.claims.hello.every',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,11 +42,29 @@ describe('ExpressionRuntime', () => {
|
|||||||
},
|
},
|
||||||
expectedResult: true,
|
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',
|
'should properly evaluate/safeEvaluate a valid conditional expression: $scenario',
|
||||||
({ condition, inputs, expectedResult }) => {
|
({ condition, inputs, expectedResult }) => {
|
||||||
expect(runtime.evaluate(condition, inputs)).toBe(expectedResult);
|
expect(runtime.evaluate(condition, inputs)).toEqual(expectedResult);
|
||||||
expect(runtime.safeEvaluate(condition, inputs).value).toBe(expectedResult);
|
expect(runtime.safeEvaluate(condition, inputs).value).toEqual(expectedResult);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export * from './errors';
|
export * from './errors';
|
||||||
export * from './input-values';
|
export * from './input-values';
|
||||||
export * from './input-values';
|
|
||||||
export * from './runtime';
|
export * from './runtime';
|
||||||
export * from './symbols';
|
export * from './symbols';
|
||||||
export * from './template';
|
export * from './template';
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
tokenizer,
|
tokenizer,
|
||||||
} from 'acorn';
|
} from 'acorn';
|
||||||
import { parse as parseLoose } from 'acorn-loose';
|
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 { AutoComplete } from './autocomplete';
|
||||||
import { ExpressionError } from './errors';
|
import { ExpressionError } from './errors';
|
||||||
@@ -49,6 +51,7 @@ export class ExpressionRuntime {
|
|||||||
return evaluate.sync<Expression>(parsed.result, inputs, {
|
return evaluate.sync<Expression>(parsed.result, inputs, {
|
||||||
functions: true,
|
functions: true,
|
||||||
withMembers: true,
|
withMembers: true,
|
||||||
|
generate: escodegen.generate,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error instanceof Error
|
throw error instanceof Error
|
||||||
|
|||||||
@@ -338,6 +338,8 @@ describe('ExpressionRuntime', () => {
|
|||||||
'visitor.claims.hello.length',
|
'visitor.claims.hello.length',
|
||||||
'visitor.claims.hello.at',
|
'visitor.claims.hello.at',
|
||||||
'visitor.claims.hello.includes',
|
'visitor.claims.hello.includes',
|
||||||
|
'visitor.claims.hello.some',
|
||||||
|
'visitor.claims.hello.every',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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).',
|
'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.',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'tsdown';
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
{
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
dts: true,
|
||||||
|
format: ['esm'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
+1
-1
@@ -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
|
* 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
|
* Enable the =~ regex operator to support testing values without using functions (example name =~ /^a.*c$/). Default: true
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function getBestUnicodeRange(text: string, ranges: Record<string, string>): stri
|
|||||||
|
|
||||||
const body = token.slice(2); // drop "U+"
|
const body = token.slice(2); // drop "U+"
|
||||||
const [startHex, endHex] = body.split('-');
|
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;
|
const end = endHex ? Number.parseInt(endHex, 16) : start;
|
||||||
|
|
||||||
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null;
|
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)) {
|
for (const [label, rangesArr] of Object.entries(parsed)) {
|
||||||
if (rangesArr.some(([lo, hi]) => cp >= lo && cp <= hi)) {
|
if (rangesArr.some(([lo, hi]) => cp >= lo && cp <= hi)) {
|
||||||
hits[label]++;
|
hits[label]!++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"target": "es2022",
|
"target": "es2022",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
|||||||
@@ -1,5 +1,74 @@
|
|||||||
# gitbook
|
# gitbook
|
||||||
|
|
||||||
|
## 0.18.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 262afa3: Expose a MCP server for the docs site under /~gitbook/mcp
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [262afa3]
|
||||||
|
- @gitbook/icons@0.3.1
|
||||||
|
- @gitbook/react-openapi@1.4.3
|
||||||
|
- @gitbook/react-contentkit@0.7.5
|
||||||
|
|
||||||
|
## 0.17.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @gitbook/react-openapi@1.4.2
|
||||||
|
|
||||||
|
## 0.17.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 24f601d: Small optim in resolveTryItPrefillForOperation
|
||||||
|
- aea5eb1: Persist language choice across sections if possible
|
||||||
|
- 1165a81: Language selector edge cases
|
||||||
|
- f9a2977: Better handling for external link "mailto:" in Hovered Card in GBO
|
||||||
|
- Updated dependencies [24f601d]
|
||||||
|
- @gitbook/react-openapi@1.4.1
|
||||||
|
|
||||||
|
## 0.17.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 12c9d76: Adapt OpenAPI blocks to eval adaptive exprs & prefill TryIt config
|
||||||
|
- 7911350: Add language selector to site header
|
||||||
|
- 659be55: Track insight event when embedded assistant is displayed.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 2e0d706: Fix corner radius of active section on "Line" sidebar style
|
||||||
|
- 78a632b: Fix edit on GitHub button doesn't take you to GitHub
|
||||||
|
- 1edc5d6: Add "hold message" to Assistant
|
||||||
|
- 4520728: Support bold in headings
|
||||||
|
- 61b8507: OpenAPI: Make responses without objects clickable
|
||||||
|
- d1fdc13: Remove ligatures from Lato font
|
||||||
|
- a8fca0e: Fix custom assistant keyboard shortcut
|
||||||
|
- 262a9b1: Fix embed script and assets being cached for too long
|
||||||
|
- c890e01: Fix order in robots.txt preventing indexation of images by Google.
|
||||||
|
- 1839ea2: Fix content min-height with sections
|
||||||
|
- 9201e2c: Adds vertical align to column block
|
||||||
|
- 193d591: Use space language as source of truth for UI locale
|
||||||
|
- f08dd29: Fix Search results are not clickable on sites without header
|
||||||
|
- 17dd382: Add `original` background color step
|
||||||
|
- 4f35882: Fix event ask_question not being tracked
|
||||||
|
- d51b79e: Fix Search bar is broken on site with sections when header is disabled
|
||||||
|
- 6f368b5: Fix embed assistant window width on small screens
|
||||||
|
- Updated dependencies [193d591]
|
||||||
|
- Updated dependencies [12c9d76]
|
||||||
|
- Updated dependencies [4927e96]
|
||||||
|
- Updated dependencies [61b8507]
|
||||||
|
- Updated dependencies [7fefe49]
|
||||||
|
- Updated dependencies [360aa1c]
|
||||||
|
- Updated dependencies [98e42cf]
|
||||||
|
- Updated dependencies [17dd382]
|
||||||
|
- @gitbook/colors@0.4.0
|
||||||
|
- @gitbook/react-openapi@1.4.0
|
||||||
|
- @gitbook/openapi-parser@3.0.2
|
||||||
|
|
||||||
## 0.16.0
|
## 0.16.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
|||||||
pageActions: {
|
pageActions: {
|
||||||
externalAI: true,
|
externalAI: true,
|
||||||
markdown: true,
|
markdown: true,
|
||||||
|
mcp: true,
|
||||||
},
|
},
|
||||||
trademark: {
|
trademark: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
"dev": {
|
"dev": {
|
||||||
"vars": {
|
"vars": {
|
||||||
"STAGE": "dev",
|
"STAGE": "dev",
|
||||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||||
|
"GITBOOK_URL": "http://localhost:8771"
|
||||||
},
|
},
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const args = process.argv.slice(2);
|
|||||||
const versionId = args[0];
|
const versionId = args[0];
|
||||||
|
|
||||||
// The preview URL is in the format https://<versionId>-gitbook-open-v2-server-preview.gitbook.workers.dev
|
// 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(
|
let updatedFile = file.replace(
|
||||||
/"PREVIEW_HOSTNAME": "TO_REPLACE"/,
|
/"PREVIEW_HOSTNAME": "TO_REPLACE"/,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.16.0",
|
"version": "0.18.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
|
"@gitbook/browser-types": "workspace:*",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@gitbook/colors": "workspace:*",
|
"@gitbook/colors": "workspace:*",
|
||||||
"@gitbook/browser-types": "workspace:*",
|
|
||||||
"@gitbook/emoji-codepoints": "workspace:*",
|
"@gitbook/emoji-codepoints": "workspace:*",
|
||||||
"@gitbook/fonts": "workspace:*",
|
"@gitbook/fonts": "workspace:*",
|
||||||
"@gitbook/icons": "workspace:*",
|
"@gitbook/icons": "workspace:*",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"@gitbook/react-contentkit": "workspace:*",
|
"@gitbook/react-contentkit": "workspace:*",
|
||||||
"@gitbook/react-math": "workspace:*",
|
"@gitbook/react-math": "workspace:*",
|
||||||
"@gitbook/react-openapi": "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-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"@tusbar/cache-control": "^1.0.2",
|
"@tusbar/cache-control": "^1.0.2",
|
||||||
"ai": "^4.2.2",
|
"ai": "^4.2.2",
|
||||||
"assert-never": "^1.2.1",
|
"assert-never": "^1.2.1",
|
||||||
|
"bidc": "catalog:",
|
||||||
"bun-types": "^1.1.20",
|
"bun-types": "^1.1.20",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"direction": "^2.0.1",
|
"direction": "^2.0.1",
|
||||||
@@ -45,7 +46,8 @@
|
|||||||
"memoizee": "^0.4.17",
|
"memoizee": "^0.4.17",
|
||||||
"micromark-extension-frontmatter": "^2.0.0",
|
"micromark-extension-frontmatter": "^2.0.0",
|
||||||
"micromark-extension-gfm": "^3.0.0",
|
"micromark-extension-gfm": "^3.0.0",
|
||||||
"next": "^15.5.0",
|
"motion": "^12.23.12",
|
||||||
|
"next": "15.4.0",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nuqs": "^2.2.3",
|
"nuqs": "^2.2.3",
|
||||||
"object-hash": "^3.0.0",
|
"object-hash": "^3.0.0",
|
||||||
@@ -71,7 +73,9 @@
|
|||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.0",
|
||||||
"warn-once": "^0.1.1",
|
"warn-once": "^0.1.1",
|
||||||
"zustand": "^5.0.3",
|
"zustand": "^5.0.3",
|
||||||
"bidc": "catalog:"
|
"mcp-handler": "^1.0.2",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||||
|
"zod": "^3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@argos-ci/playwright": "^5.0.9",
|
"@argos-ci/playwright": "^5.0.9",
|
||||||
@@ -84,7 +88,6 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/object-hash": "^3.0.6",
|
"@types/object-hash": "^3.0.6",
|
||||||
"@types/parse-cache-control": "^1.0.4",
|
"@types/parse-cache-control": "^1.0.4",
|
||||||
"@types/psi": "^4.1.6",
|
|
||||||
"@types/react": "18.3.13",
|
"@types/react": "18.3.13",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
"@types/rison": "^0.0.9",
|
"@types/rison": "^0.0.9",
|
||||||
@@ -92,7 +95,6 @@
|
|||||||
"env-cmd": "^10.1.0",
|
"env-cmd": "^10.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"psi": "^4.1.0",
|
|
||||||
"stylelint": "^16.16.0",
|
"stylelint": "^16.16.0",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"ts-essentials": "^10.0.1",
|
"ts-essentials": "^10.0.1",
|
||||||
@@ -104,7 +106,8 @@
|
|||||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
|
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
|
||||||
"dev": "env-cmd --silent -f ../../.env.local next",
|
"dev": "env-cmd --silent -f ../../.env.local next",
|
||||||
"build": "next build",
|
"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",
|
"build:cloudflare": "opennextjs-cloudflare build",
|
||||||
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
|
"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",
|
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
|
||||||
@@ -112,6 +115,7 @@
|
|||||||
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts --project=chromium",
|
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts --project=chromium",
|
||||||
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
|
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
|
||||||
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
|
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
|
||||||
|
"e2e-browserless": "bun test ./tests/",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ export default async function SiteDynamicLayout({
|
|||||||
const withTracking = shouldTrackEvents(await headers());
|
const withTracking = shouldTrackEvents(await headers());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomizationRootLayout forcedTheme={forcedTheme} customization={context.customization}>
|
<CustomizationRootLayout forcedTheme={forcedTheme} context={context}>
|
||||||
<SiteLayout
|
<SiteLayout
|
||||||
context={context}
|
context={context}
|
||||||
forcedTheme={forcedTheme}
|
forcedTheme={forcedTheme}
|
||||||
|
|||||||
+13
-2
@@ -5,6 +5,8 @@ import {
|
|||||||
generateEmbeddableViewport,
|
generateEmbeddableViewport,
|
||||||
} from '@/components/Embeddable';
|
} from '@/components/Embeddable';
|
||||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||||
|
import { shouldTrackEvents } from '@/lib/tracking';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
interface SiteStaticLayoutProps {
|
interface SiteStaticLayoutProps {
|
||||||
params: Promise<RouteLayoutParams>;
|
params: Promise<RouteLayoutParams>;
|
||||||
@@ -14,9 +16,18 @@ export default async function RootLayout({
|
|||||||
params,
|
params,
|
||||||
children,
|
children,
|
||||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
}: 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) {
|
export async function generateViewport({ params }: SiteStaticLayoutProps) {
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ export default async function SiteStaticLayout({
|
|||||||
const withTracking = shouldTrackEvents();
|
const withTracking = shouldTrackEvents();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomizationRootLayout customization={context.customization}>
|
<CustomizationRootLayout context={context}>
|
||||||
<SiteLayout
|
<SiteLayout
|
||||||
context={context}
|
context={context}
|
||||||
withTracking={withTracking}
|
withTracking={withTracking}
|
||||||
|
|||||||
+20
@@ -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);
|
||||||
|
}
|
||||||
+12
-2
@@ -5,6 +5,7 @@ import {
|
|||||||
generateEmbeddableViewport,
|
generateEmbeddableViewport,
|
||||||
} from '@/components/Embeddable';
|
} from '@/components/Embeddable';
|
||||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||||
|
import { shouldTrackEvents } from '@/lib/tracking';
|
||||||
|
|
||||||
interface SiteStaticLayoutProps {
|
interface SiteStaticLayoutProps {
|
||||||
params: Promise<RouteLayoutParams>;
|
params: Promise<RouteLayoutParams>;
|
||||||
@@ -14,9 +15,18 @@ export default async function RootLayout({
|
|||||||
params,
|
params,
|
||||||
children,
|
children,
|
||||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
}: 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) {
|
export async function generateViewport({ params }: SiteStaticLayoutProps) {
|
||||||
|
|||||||
+4
-4
@@ -1,5 +1,6 @@
|
|||||||
import type { RouteLayoutParams } from '@/app/utils';
|
import type { RouteLayoutParams } from '@/app/utils';
|
||||||
import { getAssetURL } from '@/lib/assets';
|
import { getAssetURL } from '@/lib/assets';
|
||||||
|
import { buildVersion } from '@/lib/build';
|
||||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||||
import type { CreateGitBookOptions } from '@gitbook/embed';
|
import type { CreateGitBookOptions } from '@gitbook/embed';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
@@ -14,7 +15,6 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<RouteLayoutParams> }
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
) {
|
) {
|
||||||
const { context } = await getEmbeddableStaticContext(await params);
|
const { context } = await getEmbeddableStaticContext(await params);
|
||||||
const maxAge = 7 * 24 * 60 * 60;
|
|
||||||
const initOptions: CreateGitBookOptions = {
|
const initOptions: CreateGitBookOptions = {
|
||||||
siteURL: context.linker.toAbsoluteURL(context.linker.toPathInSite('')),
|
siteURL: context.linker.toAbsoluteURL(context.linker.toPathInSite('')),
|
||||||
};
|
};
|
||||||
@@ -45,13 +45,13 @@ export async function GET(
|
|||||||
const load = function () {
|
const load = function () {
|
||||||
const style = document.createElement('link');
|
const style = document.createElement('link');
|
||||||
style.rel = 'stylesheet';
|
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);
|
document.head.appendChild(style);
|
||||||
|
|
||||||
const script = d.createElement('script');
|
const script = d.createElement('script');
|
||||||
script.type = 'text/javascript';
|
script.type = 'text/javascript';
|
||||||
script.async = true;
|
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];
|
var latestScript = d.getElementsByTagName('script')[0];
|
||||||
latestScript.parentNode.insertBefore(script, latestScript);
|
latestScript.parentNode.insertBefore(script, latestScript);
|
||||||
@@ -70,7 +70,7 @@ export async function GET(
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/javascript',
|
'Content-Type': 'application/javascript',
|
||||||
'Cache-Control': `public, max-age=${maxAge}, immutable`,
|
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||||
|
import { throwIfDataError } from '@/lib/data';
|
||||||
|
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||||
|
import { findSiteSpaceBy } from '@/lib/sites';
|
||||||
|
import { createMcpHandler } from 'mcp-handler';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
async function handler(
|
||||||
|
nextRequest: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
|
) {
|
||||||
|
const { context } = await getStaticSiteContext(await params);
|
||||||
|
const { dataFetcher, linker, site } = context;
|
||||||
|
|
||||||
|
const mcpHandler = createMcpHandler(
|
||||||
|
(server) => {
|
||||||
|
server.tool(
|
||||||
|
'searchDocumentation',
|
||||||
|
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
|
||||||
|
{
|
||||||
|
query: z.string(),
|
||||||
|
},
|
||||||
|
async ({ query }) => {
|
||||||
|
const results = await throwIfDataError(
|
||||||
|
dataFetcher.searchSiteContent({
|
||||||
|
organizationId: context.organizationId,
|
||||||
|
siteId: site.id,
|
||||||
|
query,
|
||||||
|
scope: { mode: 'all' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: results.flatMap((spaceResult) => {
|
||||||
|
const found = findSiteSpaceBy(
|
||||||
|
context.structure,
|
||||||
|
(siteSpace) => siteSpace.space.id === spaceResult.id
|
||||||
|
);
|
||||||
|
const spaceURL = found?.siteSpace.urls.published;
|
||||||
|
if (!spaceURL) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return spaceResult.pages.map((pageResult) => {
|
||||||
|
const pageURL = linker.toAbsoluteURL(
|
||||||
|
linker.toLinkForContent(
|
||||||
|
joinPathWithBaseURL(spaceURL, pageResult.path)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = pageResult.sections
|
||||||
|
?.map((section) => section.body)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'text',
|
||||||
|
text: [
|
||||||
|
`Title: ${pageResult.title}`,
|
||||||
|
`Link: ${pageURL}`,
|
||||||
|
body ? `Content: ${body}` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
basePath: context.linker.toPathInSite('~gitbook/'),
|
||||||
|
streamableHttpEndpoint: '/mcp',
|
||||||
|
maxDuration: 60,
|
||||||
|
verboseLogs: true,
|
||||||
|
disableSse: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Next.js request.url is the original URL and not the rewritten one from the middleware
|
||||||
|
const requestURL = new URL(
|
||||||
|
context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/mcp'))
|
||||||
|
);
|
||||||
|
requestURL.search = nextRequest.nextUrl.search;
|
||||||
|
|
||||||
|
const request = new Request(requestURL, nextRequest);
|
||||||
|
return mcpHandler(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './useAI';
|
export * from './useAI';
|
||||||
export * from './useAIChat';
|
export * from './useAIChat';
|
||||||
|
export type { RenderAIMessageOptions } from './server-actions';
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ function DescriptionForPageContentToolCall(props: {
|
|||||||
}) {
|
}) {
|
||||||
const { toolCall, context } = props;
|
const { toolCall, context } = props;
|
||||||
|
|
||||||
const language = getSpaceLanguage(context.customization);
|
const language = getSpaceLanguage(context);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p>
|
<p>
|
||||||
@@ -102,7 +102,7 @@ function DescriptionForMCPToolCall(props: {
|
|||||||
}) {
|
}) {
|
||||||
const { toolCall, context } = props;
|
const { toolCall, context } = props;
|
||||||
|
|
||||||
const language = getSpaceLanguage(context.customization);
|
const language = getSpaceLanguage(context);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p>
|
<p>
|
||||||
@@ -130,7 +130,7 @@ async function DescriptionForSearchToolCall(props: {
|
|||||||
}) {
|
}) {
|
||||||
const { toolCall, context } = props;
|
const { toolCall, context } = props;
|
||||||
|
|
||||||
const language = getSpaceLanguage(context.customization);
|
const language = getSpaceLanguage(context);
|
||||||
|
|
||||||
// Resolve all hrefs for search results in parallel
|
// Resolve all hrefs for search results in parallel
|
||||||
const searchResultsWithHrefs = await Promise.all(
|
const searchResultsWithHrefs = await Promise.all(
|
||||||
@@ -187,7 +187,7 @@ async function DescriptionForSearchToolCall(props: {
|
|||||||
) : null}
|
) : null}
|
||||||
</summary>
|
</summary>
|
||||||
{hasResults ? (
|
{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">
|
<ol className="space-y-1">
|
||||||
{searchResultsWithHrefs.map((result, index) => (
|
{searchResultsWithHrefs.map((result, index) => (
|
||||||
<li
|
<li
|
||||||
@@ -241,7 +241,7 @@ function DescriptionForGetPagesToolCall(props: {
|
|||||||
}) {
|
}) {
|
||||||
const { toolCall, context } = props;
|
const { toolCall, context } = props;
|
||||||
|
|
||||||
const language = getSpaceLanguage(context.customization);
|
const language = getSpaceLanguage(context);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ export async function streamRenderAIMessage(
|
|||||||
|
|
||||||
if (message.steps[stepIndex]) {
|
if (message.steps[stepIndex]) {
|
||||||
message.steps = [...message.steps];
|
message.steps = [...message.steps];
|
||||||
|
// @ts-expect-error
|
||||||
message.steps[stepIndex] = { ...message.steps[stepIndex] };
|
message.steps[stepIndex] = { ...message.steps[stepIndex] };
|
||||||
|
// @ts-expect-error
|
||||||
callback(message.steps[stepIndex]);
|
callback(message.steps[stepIndex]);
|
||||||
} else {
|
} else {
|
||||||
message.steps = [
|
message.steps = [
|
||||||
@@ -52,6 +54,7 @@ export async function streamRenderAIMessage(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
// @ts-expect-error
|
||||||
callback(message.steps[stepIndex]);
|
callback(message.steps[stepIndex]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function useAI(): AIContext {
|
|||||||
setSearchState((prev) => ({
|
setSearchState((prev) => ({
|
||||||
ask: null, // Reset ask as we assume the assistant will handle it
|
ask: null, // Reset ask as we assume the assistant will handle it
|
||||||
query: prev?.query ?? null,
|
query: prev?.query ?? null,
|
||||||
global: prev?.global ?? false,
|
scope: prev?.scope ?? 'default',
|
||||||
open: false,
|
open: false,
|
||||||
}));
|
}));
|
||||||
assistant.open(query);
|
assistant.open(query);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type AIChatMessage = {
|
|||||||
role: AIMessageRole;
|
role: AIMessageRole;
|
||||||
content: React.ReactNode;
|
content: React.ReactNode;
|
||||||
query?: string;
|
query?: string;
|
||||||
|
note?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AIChatPendingTool = {
|
export type AIChatPendingTool = {
|
||||||
@@ -146,7 +147,7 @@ export function AIChatProvider(props: {
|
|||||||
setSearchState((prev) => ({
|
setSearchState((prev) => ({
|
||||||
ask: prev?.ask ?? initialQuery ?? '',
|
ask: prev?.ask ?? initialQuery ?? '',
|
||||||
query: prev?.query ?? null,
|
query: prev?.query ?? null,
|
||||||
global: prev?.global ?? false,
|
scope: prev?.scope ?? 'default',
|
||||||
open: false, // Close search popover when opening chat
|
open: false, // Close search popover when opening chat
|
||||||
}));
|
}));
|
||||||
}, [setSearchState]);
|
}, [setSearchState]);
|
||||||
@@ -159,7 +160,7 @@ export function AIChatProvider(props: {
|
|||||||
setSearchState((prev) => ({
|
setSearchState((prev) => ({
|
||||||
ask: null,
|
ask: null,
|
||||||
query: prev?.query ?? null,
|
query: prev?.query ?? null,
|
||||||
global: prev?.global ?? false,
|
scope: prev?.scope ?? 'default',
|
||||||
open: false,
|
open: false,
|
||||||
}));
|
}));
|
||||||
}, [setSearchState]);
|
}, [setSearchState]);
|
||||||
@@ -374,7 +375,7 @@ export function AIChatProvider(props: {
|
|||||||
setSearchState((prev) => ({
|
setSearchState((prev) => ({
|
||||||
ask: input.message,
|
ask: input.message,
|
||||||
query: prev?.query ?? null,
|
query: prev?.query ?? null,
|
||||||
global: prev?.global ?? false,
|
scope: prev?.scope ?? 'default',
|
||||||
open: false,
|
open: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -391,11 +392,11 @@ export function AIChatProvider(props: {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
messages: [
|
messages: [
|
||||||
...state.messages,
|
|
||||||
{
|
{
|
||||||
role: AIMessageRole.User,
|
role: AIMessageRole.User,
|
||||||
content: input.message,
|
content: input.message,
|
||||||
query: input.message,
|
query: input.message,
|
||||||
|
note: state.messages.length > 0 ? 'Starting a new chat' : undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
query: input.message,
|
query: input.message,
|
||||||
@@ -435,7 +436,7 @@ export function AIChatProvider(props: {
|
|||||||
setSearchState((prev) => ({
|
setSearchState((prev) => ({
|
||||||
ask: '',
|
ask: '',
|
||||||
query: prev?.query ?? null,
|
query: prev?.query ?? null,
|
||||||
global: prev?.global ?? false,
|
scope: prev?.scope ?? 'default',
|
||||||
open: false,
|
open: false,
|
||||||
}));
|
}));
|
||||||
}, [setSearchState]);
|
}, [setSearchState]);
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CopyMarkdown,
|
|
||||||
OpenAIAssistant,
|
|
||||||
OpenInLLM,
|
|
||||||
ViewAsMarkdown,
|
|
||||||
} from '@/components/AIActions/AIActions';
|
|
||||||
import { Button, ButtonGroup } from '@/components/primitives/Button';
|
|
||||||
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
|
|
||||||
import { tString, useLanguage } from '@/intl/client';
|
|
||||||
import type { SiteCustomizationSettings } from '@gitbook/api';
|
|
||||||
import { Icon } from '@gitbook/icons';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
import { useAI } from '../AI';
|
|
||||||
|
|
||||||
interface AIActionsDropdownProps {
|
|
||||||
markdownPageUrl: string;
|
|
||||||
className?: string;
|
|
||||||
actions: SiteCustomizationSettings['pageActions'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM).
|
|
||||||
*/
|
|
||||||
export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const assistants = useAI().assistants.filter(
|
|
||||||
(assistant) => assistant.ui === true && assistant.pageAction
|
|
||||||
);
|
|
||||||
const language = useLanguage();
|
|
||||||
|
|
||||||
return assistants.length > 0 || props.actions.markdown || props.actions.externalAI ? (
|
|
||||||
<ButtonGroup ref={ref} className={props.className}>
|
|
||||||
<DefaultAction {...props} />
|
|
||||||
{props.actions.markdown || props.actions.externalAI ? (
|
|
||||||
<DropdownMenu
|
|
||||||
align="end"
|
|
||||||
className="!min-w-60 max-w-max"
|
|
||||||
button={
|
|
||||||
<Button
|
|
||||||
icon={
|
|
||||||
<Icon
|
|
||||||
icon="chevron-down"
|
|
||||||
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={tString(language, 'more')}
|
|
||||||
iconOnly
|
|
||||||
size="xsmall"
|
|
||||||
variant="secondary"
|
|
||||||
className="bg-tint-base text-sm"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AIActionsDropdownMenuContent {...props} />
|
|
||||||
</DropdownMenu>
|
|
||||||
) : null}
|
|
||||||
</ButtonGroup>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The content of the dropdown menu.
|
|
||||||
*/
|
|
||||||
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
|
|
||||||
const { markdownPageUrl, actions } = props;
|
|
||||||
const assistants = useAI().assistants.filter(
|
|
||||||
(assistant) => assistant.ui === true && assistant.pageAction
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{assistants.map((assistant) => (
|
|
||||||
<OpenAIAssistant
|
|
||||||
key={assistant.label}
|
|
||||||
assistant={assistant}
|
|
||||||
type="dropdown-menu-item"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{actions.markdown ? (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator className="first:hidden" />
|
|
||||||
<CopyMarkdown
|
|
||||||
isDefaultAction={!assistants.length}
|
|
||||||
markdownPageUrl={markdownPageUrl}
|
|
||||||
type="dropdown-menu-item"
|
|
||||||
/>
|
|
||||||
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{actions.externalAI ? (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator className="first:hidden" />
|
|
||||||
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="dropdown-menu-item" />
|
|
||||||
<OpenInLLM provider="claude" url={markdownPageUrl} type="dropdown-menu-item" />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A default action shown as a quick-access button beside the dropdown menu
|
|
||||||
*/
|
|
||||||
function DefaultAction(props: AIActionsDropdownProps) {
|
|
||||||
const { markdownPageUrl, actions } = props;
|
|
||||||
const assistants = useAI().assistants.filter(
|
|
||||||
(assistant) => assistant.ui === true && assistant.pageAction
|
|
||||||
);
|
|
||||||
|
|
||||||
if (assistants.length) {
|
|
||||||
return <OpenAIAssistant assistant={assistants[0]} type="button" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actions.markdown) {
|
|
||||||
return (
|
|
||||||
<CopyMarkdown
|
|
||||||
isDefaultAction={!assistants.length}
|
|
||||||
markdownPageUrl={markdownPageUrl}
|
|
||||||
type="button"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actions.externalAI) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="button" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export function ChatGPTIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" {...props}>
|
|
||||||
<title>ChatGPT</title>
|
|
||||||
<path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
export function ClaudeIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 256 257"
|
|
||||||
version="1.1"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
preserveAspectRatio="xMidYMid"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<title>Claude</title>
|
|
||||||
<g>
|
|
||||||
<path
|
|
||||||
d="M50.2278481,170.321013 L100.585316,142.063797 L101.427848,139.601013 L100.585316,138.24 L98.1225316,138.24 L89.6972152,137.721519 L60.921519,136.943797 L35.9696203,135.906835 L11.795443,134.610633 L5.70329114,133.31443 L0,125.796456 L0.583291139,122.037468 L5.70329114,118.602532 L13.0268354,119.250633 L29.2293671,120.352405 L53.5331646,122.037468 L71.161519,123.07443 L97.28,125.796456 L101.427848,125.796456 L102.011139,124.111392 L100.585316,123.07443 L99.4835443,122.037468 L74.3372152,104.992405 L47.116962,86.9751899 L32.8587342,76.6055696 L25.1463291,71.3559494 L21.2577215,66.4303797 L19.5726582,55.6718987 L26.5721519,47.9594937 L35.9696203,48.6075949 L38.3675949,49.2556962 L47.8946835,56.5792405 L68.2450633,72.3281013 L94.8172152,91.9007595 L98.7058228,95.1412658 L100.261266,94.0394937 L100.455696,93.2617722 L98.7058228,90.3453165 L84.2531646,64.2268354 L68.8283544,37.6546835 L61.958481,26.636962 L60.1437975,20.0263291 C59.4956962,17.3043038 59.0420253,15.0359494 59.0420253,12.2491139 L67.0136709,1.42582278 L71.4207595,-1.42108547e-14 L82.0496203,1.42582278 L86.521519,5.31443038 L93.1321519,20.4151899 L103.825823,44.2005063 L120.417215,76.5407595 L125.277975,86.1326582 L127.87038,95.0116456 L128.842532,97.7336709 L130.527595,97.7336709 L130.527595,96.1782278 L131.888608,77.9665823 L134.416203,55.6070886 L136.878987,26.8313924 L137.721519,18.7301266 L141.739747,9.00860759 L149.711392,3.75898734 L155.933165,6.74025316 L161.053165,14.0637975 L160.340253,18.7949367 L157.294177,38.5620253 L151.331646,69.5412658 L147.443038,90.2805063 L149.711392,90.2805063 L152.303797,87.6881013 L162.803038,73.7539241 L180.431392,51.718481 L188.208608,42.9691139 L197.282025,33.3124051 L203.114937,28.7108861 L214.132658,28.7108861 L222.233924,40.7655696 L218.604557,53.2091139 L207.262785,67.596962 L197.865316,79.7812658 L184.38481,97.9281013 L175.959494,112.44557 L176.737215,113.612152 L178.746329,113.417722 L209.207089,106.936709 L225.668861,103.955443 L245.306329,100.585316 L254.185316,104.733165 L255.157468,108.945823 L251.657722,117.56557 L230.659241,122.75038 L206.031392,127.675949 L169.348861,136.360506 L168.89519,136.684557 L169.413671,137.332658 L185.940253,138.888101 L193.004557,139.276962 L210.308861,139.276962 L242.519494,141.674937 L250.94481,147.248608 L256,154.053671 L255.157468,159.238481 L242.195443,165.849114 L224.696709,161.701266 L183.866329,151.979747 L169.867342,148.48 L167.923038,148.48 L167.923038,149.646582 L179.588861,161.053165 L200.976203,180.366582 L227.742785,205.253671 L229.103797,211.410633 L225.668861,216.271392 L222.039494,215.752911 L198.513418,198.059747 L189.44,190.088101 L168.89519,172.783797 L167.534177,172.783797 L167.534177,174.598481 L172.265316,181.533165 L197.282025,219.123038 L198.578228,230.659241 L196.763544,234.418228 L190.282532,236.686582 L183.153418,235.39038 L168.506329,214.84557 L153.40557,191.708354 L141.221266,170.969114 L139.730633,171.811646 L132.536709,249.259747 L129.166582,253.213165 L121.389367,256.19443 L114.908354,251.268861 L111.473418,243.297215 L114.908354,227.548354 L119.056203,207.003544 L122.426329,190.671392 L125.472405,170.385823 L127.287089,163.64557 L127.157468,163.191899 L125.666835,163.386329 L110.371646,184.38481 L87.1048101,215.817722 L68.6987342,235.52 L64.2916456,237.269873 L56.6440506,233.316456 L57.356962,226.252152 L61.6344304,219.96557 L87.1048101,187.560506 L102.46481,167.469367 L112.380759,155.868354 L112.315949,154.183291 L111.732658,154.183291 L44.0708861,198.124557 L32.0162025,199.68 L26.8313924,194.819241 L27.4794937,186.847595 L29.9422785,184.25519 L50.2926582,170.256203 L50.2278481,170.321013 Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export function MarkdownIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 471 289.85" {...props}>
|
|
||||||
<title>Markdown</title>
|
|
||||||
<path d="M437,289.85H34a34,34,0,0,1-34-34V34A34,34,0,0,1,34,0H437a34,34,0,0,1,34,34V255.88A34,34,0,0,1,437,289.85ZM34,22.64A11.34,11.34,0,0,0,22.64,34V255.88A11.34,11.34,0,0,0,34,267.2H437a11.34,11.34,0,0,0,11.33-11.32V34A11.34,11.34,0,0,0,437,22.64Z" />
|
|
||||||
<path d="M67.93,221.91v-154h45.29l45.29,56.61L203.8,67.93h45.29v154H203.8V133.6l-45.29,56.61L113.22,133.6v88.31Zm283.06,0-67.94-74.72h45.29V67.93h45.29v79.26h45.29Z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
EmbeddableFrameSubtitle,
|
EmbeddableFrameSubtitle,
|
||||||
EmbeddableFrameTitle,
|
EmbeddableFrameTitle,
|
||||||
} from '../Embeddable/EmbeddableFrame';
|
} from '../Embeddable/EmbeddableFrame';
|
||||||
|
import { useTrackEvent } from '../Insights';
|
||||||
import { useNow } from '../hooks';
|
import { useNow } from '../hooks';
|
||||||
import { Button } from '../primitives';
|
import { Button } from '../primitives';
|
||||||
import { AIChatControlButton } from './AIChatControlButton';
|
import { AIChatControlButton } from './AIChatControlButton';
|
||||||
@@ -46,17 +47,6 @@ export function AIChat(props: { trademark: boolean }) {
|
|||||||
}
|
}
|
||||||
}, [chat.opened]);
|
}, [chat.opened]);
|
||||||
|
|
||||||
useHotkeys(
|
|
||||||
'mod+i',
|
|
||||||
(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
chatController.open();
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enableOnFormTags: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'esc',
|
'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) {
|
if (!chat.opened) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -224,7 +224,7 @@ export function AIChatBody(props: {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
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={{
|
style={{
|
||||||
paddingBottom: `${inputHeight}px`,
|
paddingBottom: `${inputHeight}px`,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
|||||||
export function AIChatButton(props: {
|
export function AIChatButton(props: {
|
||||||
assistant: Assistant;
|
assistant: Assistant;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
|
withShortcut?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { assistant, showLabel = true } = props;
|
const { assistant, showLabel = true, withShortcut = true } = props;
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +28,12 @@ export function AIChatButton(props: {
|
|||||||
label={
|
label={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{t(language, 'ai_chat_ask', assistant.label)}
|
{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>
|
</div>
|
||||||
}
|
}
|
||||||
onClick={() => assistant.open()}
|
onClick={() => assistant.open()}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
|
import { useLanguage } from '@/intl/client';
|
||||||
|
import { tString } from '@/intl/translate';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { AIMessageRole } from '@gitbook/api';
|
import { AIMessageRole } from '@gitbook/api';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import type { AIChatController, AIChatState } from '../AI';
|
import type { AIChatController, AIChatState } from '../AI';
|
||||||
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
|
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
|
||||||
import { AIResponseFeedback } from './AIResponseFeedback';
|
import { AIResponseFeedback } from './AIResponseFeedback';
|
||||||
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
|
||||||
|
|
||||||
export function AIChatMessages(props: {
|
export function AIChatMessages(props: {
|
||||||
chat: AIChatState;
|
chat: AIChatState;
|
||||||
chatController: AIChatController;
|
chatController: AIChatController;
|
||||||
lastUserMessageRef?: React.RefObject<HTMLDivElement>;
|
lastUserMessageRef?: React.RefObject<HTMLDivElement>;
|
||||||
}) {
|
}) {
|
||||||
const { chat, chatController, lastUserMessageRef } = props;
|
const { chat, lastUserMessageRef } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -22,67 +23,117 @@ export function AIChatMessages(props: {
|
|||||||
index === chat.messages.map((m) => m.role).lastIndexOf(AIMessageRole.User);
|
index === chat.messages.map((m) => m.role).lastIndexOf(AIMessageRole.User);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
ref={isLastUserMessage ? lastUserMessageRef : undefined}
|
{message.note ? (
|
||||||
data-testid="ai-chat-message"
|
<div
|
||||||
className={tcls(
|
key={`${index}-note`}
|
||||||
message.content ? 'animate-fade-in-slow' : '',
|
className="self-center text-tint-subtle text-xs"
|
||||||
'shrink-0',
|
>
|
||||||
'last:min-h-[calc(100%-5rem)]',
|
{message.note}
|
||||||
'scroll-mt-36',
|
|
||||||
'lg:scroll-mt-0',
|
|
||||||
'flex flex-col gap-6',
|
|
||||||
'break-words',
|
|
||||||
'group/message',
|
|
||||||
message.role === AIMessageRole.User
|
|
||||||
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
|
||||||
: 'text-tint-strong'
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
|
|
||||||
}}
|
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
{message.content ? message.content : 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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div
|
||||||
|
ref={isLastUserMessage ? lastUserMessageRef : undefined}
|
||||||
|
data-testid="ai-chat-message"
|
||||||
|
className={tcls(
|
||||||
|
message.content ? 'animate-fade-in-slow' : '',
|
||||||
|
'shrink-0',
|
||||||
|
'last:min-h-[calc(100%-5rem)]',
|
||||||
|
'scroll-mt-36',
|
||||||
|
'lg:scroll-mt-0',
|
||||||
|
'flex flex-col gap-6',
|
||||||
|
'break-words',
|
||||||
|
'group/message',
|
||||||
|
message.role === AIMessageRole.User
|
||||||
|
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
||||||
|
: 'text-tint-strong'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
|
||||||
|
}}
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
{message.content ? message.content : null}
|
||||||
|
|
||||||
{isLastMessage ? (
|
{isLastMessage && chat.loading ? (
|
||||||
<>
|
<div className="flex w-full animate-fade-in-slow flex-col gap-2">
|
||||||
{!chat.loading &&
|
{!message.content ? <HoldMessage /> : null}
|
||||||
!chat.error &&
|
<LoadingSkeleton />
|
||||||
chat.query &&
|
</div>
|
||||||
chat.responseId &&
|
) : null}
|
||||||
chat.pendingTools.length === 0 ? (
|
|
||||||
<AIResponseFeedback
|
{isLastMessage ? (
|
||||||
responseId={chat.responseId}
|
<>
|
||||||
query={chat.query}
|
{!chat.loading &&
|
||||||
className="-ml-1 -mt-4"
|
!chat.error &&
|
||||||
/>
|
chat.query &&
|
||||||
) : null}
|
chat.responseId &&
|
||||||
<AIChatToolConfirmations chat={chat} />
|
chat.pendingTools.length === 0 ? (
|
||||||
<AIChatFollowupSuggestions
|
<AIResponseFeedback
|
||||||
chat={chat}
|
responseId={chat.responseId}
|
||||||
chatController={chatController}
|
query={chat.query}
|
||||||
/>
|
className="-ml-1 -mt-4"
|
||||||
</>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
<AIChatToolConfirmations chat={chat} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,142 +1,63 @@
|
|||||||
import type { GitBookSiteContext } from '@/lib/context';
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
import { Icon } from '@gitbook/icons';
|
import { AdminToolbarClient } from './AdminToolbarClient';
|
||||||
import { headers } from 'next/headers';
|
import type { AdminToolbarContext } from './types';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { tcls } from '@/lib/tailwind';
|
export interface AdminToolbarProps {
|
||||||
|
|
||||||
import { DateRelative } from '../primitives';
|
|
||||||
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
|
|
||||||
import { Toolbar, ToolbarBody, ToolbarButton, ToolbarButtonGroups } from './Toolbar';
|
|
||||||
|
|
||||||
interface AdminToolbarProps {
|
|
||||||
context: GitBookSiteContext;
|
context: GitBookSiteContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolbarLayout(props: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={tcls(
|
|
||||||
'fixed',
|
|
||||||
'bottom-5',
|
|
||||||
'left-1/2',
|
|
||||||
'z-50',
|
|
||||||
'transform',
|
|
||||||
'-translate-x-1/2',
|
|
||||||
'rounded-full',
|
|
||||||
|
|
||||||
'bg-tint-12/9',
|
|
||||||
'dark:bg-tint-1/9',
|
|
||||||
|
|
||||||
'shadow-lg',
|
|
||||||
'min-h-10',
|
|
||||||
'min-w-40',
|
|
||||||
'p-2',
|
|
||||||
'max-w-md',
|
|
||||||
'border-tint-12/1',
|
|
||||||
'backdrop-blur-md'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<React.Suspense fallback={null}>{props.children}</React.Suspense>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toolbar with information for the content admin when previewing a revision or change-request.
|
* Server component that determines what type of toolbar to show and passes data to client component
|
||||||
*/
|
*/
|
||||||
export async function AdminToolbar(props: AdminToolbarProps) {
|
export async function AdminToolbar(props: AdminToolbarProps) {
|
||||||
const { context } = props;
|
const { context } = props;
|
||||||
const mode = (await headers()).get('x-gitbook-mode');
|
|
||||||
|
|
||||||
if (mode === 'multi-id') {
|
// Create a minimal context to avoid serializing and passing too many data to the client
|
||||||
// We don't show the admin toolbar in multi-id mode, as it's used for previewing in the dashboard.
|
const minimalContext: AdminToolbarContext = {
|
||||||
return null;
|
organizationId: context.organizationId,
|
||||||
}
|
revisionId: context.revisionId,
|
||||||
|
space: {
|
||||||
|
id: context.space.id,
|
||||||
|
revision: context.space.revision,
|
||||||
|
urls: {
|
||||||
|
app: context.space.urls.app,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
changeRequest: context.changeRequest
|
||||||
|
? {
|
||||||
|
id: context.changeRequest.id,
|
||||||
|
number: context.changeRequest.number,
|
||||||
|
subject: context.changeRequest.subject,
|
||||||
|
revision: context.changeRequest.revision,
|
||||||
|
updatedAt: context.changeRequest.updatedAt,
|
||||||
|
createdBy: {
|
||||||
|
displayName: context.changeRequest.createdBy.displayName,
|
||||||
|
},
|
||||||
|
urls: {
|
||||||
|
app: context.changeRequest.urls.app,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
revision: {
|
||||||
|
createdAt: context.revision.createdAt,
|
||||||
|
urls: {
|
||||||
|
app: context.revision.urls.app,
|
||||||
|
},
|
||||||
|
git: context.revision.git
|
||||||
|
? {
|
||||||
|
url: context.revision.git.url,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
site: {
|
||||||
|
id: context.site.id,
|
||||||
|
title: context.site.title,
|
||||||
|
urls: {
|
||||||
|
app: context.site.urls.app,
|
||||||
|
published: context.site.urls.published,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
if (context.changeRequest) {
|
return <AdminToolbarClient context={minimalContext} />;
|
||||||
return <ChangeRequestToolbar context={context} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.revisionId !== context.space.revision) {
|
|
||||||
return <RevisionToolbar context={context} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ChangeRequestToolbar(props: { context: GitBookSiteContext }) {
|
|
||||||
const { context } = props;
|
|
||||||
const { space, changeRequest } = context;
|
|
||||||
|
|
||||||
if (!changeRequest) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToolbarLayout>
|
|
||||||
<Toolbar>
|
|
||||||
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
|
|
||||||
<Icon icon="code-branch" className="size-4" />
|
|
||||||
</ToolbarButton>
|
|
||||||
<ToolbarBody>
|
|
||||||
<p>
|
|
||||||
#{changeRequest.number}: {changeRequest.subject ?? 'No subject'}
|
|
||||||
</p>
|
|
||||||
<p className="text-tint-2 text-xs dark:text-tint-11">
|
|
||||||
Change request updated <DateRelative value={changeRequest.updatedAt} />
|
|
||||||
</p>
|
|
||||||
</ToolbarBody>
|
|
||||||
<ToolbarButtonGroups>
|
|
||||||
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
|
|
||||||
<Icon icon="arrow-up-right-from-square" className="size-4" />
|
|
||||||
</ToolbarButton>
|
|
||||||
<RefreshChangeRequestButton
|
|
||||||
spaceId={space.id}
|
|
||||||
changeRequestId={changeRequest.id}
|
|
||||||
revisionId={changeRequest.revision}
|
|
||||||
updatedAt={new Date(changeRequest.updatedAt).getTime()}
|
|
||||||
/>
|
|
||||||
</ToolbarButtonGroups>
|
|
||||||
</Toolbar>
|
|
||||||
</ToolbarLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function RevisionToolbar(props: { context: GitBookSiteContext }) {
|
|
||||||
const { context } = props;
|
|
||||||
const { revision } = context;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToolbarLayout>
|
|
||||||
<Toolbar>
|
|
||||||
<ToolbarButton title="Open in application" href={revision.urls.app}>
|
|
||||||
<Icon icon="code-commit" className="size-4" />
|
|
||||||
</ToolbarButton>
|
|
||||||
<ToolbarBody>
|
|
||||||
<p>
|
|
||||||
Revision created <DateRelative value={revision.createdAt} />
|
|
||||||
</p>
|
|
||||||
{revision.git ? (
|
|
||||||
<p className="text-tint-2 text-xs dark:text-tint-11">
|
|
||||||
{revision.git.message}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</ToolbarBody>
|
|
||||||
<ToolbarButtonGroups>
|
|
||||||
<ToolbarButton title="Open in application" href={revision.urls.app}>
|
|
||||||
<Icon icon="arrow-up-right-from-square" className="size-4" />
|
|
||||||
</ToolbarButton>
|
|
||||||
{revision.git?.url ? (
|
|
||||||
<ToolbarButton title="Open git commit" href={revision.git.url}>
|
|
||||||
<Icon
|
|
||||||
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
|
|
||||||
className="size-4"
|
|
||||||
/>
|
|
||||||
</ToolbarButton>
|
|
||||||
) : null}
|
|
||||||
</ToolbarButtonGroups>
|
|
||||||
</Toolbar>
|
|
||||||
</ToolbarLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
'use client';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
|
import { MotionConfig } from 'motion/react';
|
||||||
|
import { useCheckForContentUpdate } from '../AutoRefreshContent';
|
||||||
|
import { useVisitorSession } from '../Insights';
|
||||||
|
import { useCurrentPagePath } from '../hooks';
|
||||||
|
import { DateRelative } from '../primitives';
|
||||||
|
import { IframeWrapper } from './IframeWrapper';
|
||||||
|
import { RefreshContentButton } from './RefreshContentButton';
|
||||||
|
import {
|
||||||
|
Toolbar,
|
||||||
|
ToolbarBody,
|
||||||
|
ToolbarButton,
|
||||||
|
ToolbarButtonGroup,
|
||||||
|
type ToolbarButtonProps,
|
||||||
|
ToolbarSeparator,
|
||||||
|
ToolbarSubtitle,
|
||||||
|
ToolbarTitle,
|
||||||
|
} from './Toolbar';
|
||||||
|
import type { AdminToolbarClientProps } from './types';
|
||||||
|
|
||||||
|
export function AdminToolbarClient(props: AdminToolbarClientProps) {
|
||||||
|
const { context } = props;
|
||||||
|
const visitorSession = useVisitorSession();
|
||||||
|
|
||||||
|
// If there is a change request, show the change request toolbar
|
||||||
|
if (context.changeRequest) {
|
||||||
|
return (
|
||||||
|
<IframeWrapper>
|
||||||
|
<MotionConfig reducedMotion="user">
|
||||||
|
<ChangeRequestToolbar context={context} />
|
||||||
|
</MotionConfig>
|
||||||
|
</IframeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the revision is not the current revision, the user is looking at a previous version of the site, so show the revision toolbar
|
||||||
|
if (context.revisionId !== context.space.revision) {
|
||||||
|
return (
|
||||||
|
<IframeWrapper>
|
||||||
|
<MotionConfig reducedMotion="user">
|
||||||
|
<RevisionToolbar context={context} />
|
||||||
|
</MotionConfig>
|
||||||
|
</IframeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
|
||||||
|
if (visitorSession?.organizationId === context.organizationId) {
|
||||||
|
return (
|
||||||
|
<IframeWrapper>
|
||||||
|
<MotionConfig reducedMotion="user">
|
||||||
|
<AuthenticatedUserToolbar context={context} />
|
||||||
|
</MotionConfig>
|
||||||
|
</IframeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangeRequestToolbar(props: AdminToolbarClientProps) {
|
||||||
|
const { context } = props;
|
||||||
|
const { changeRequest, site } = context;
|
||||||
|
if (!changeRequest) {
|
||||||
|
throw new Error('Change request is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
const author = changeRequest.createdBy.displayName;
|
||||||
|
|
||||||
|
const { refreshForUpdates, updated } = useCheckForContentUpdate({
|
||||||
|
revisionId: changeRequest.revision,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toolbar>
|
||||||
|
<ToolbarBody>
|
||||||
|
<ToolbarTitle
|
||||||
|
prefix="Change request"
|
||||||
|
suffix={`#${changeRequest.number} ${changeRequest.subject || 'Untitled'}`}
|
||||||
|
/>
|
||||||
|
<ToolbarSubtitle
|
||||||
|
subtitle={
|
||||||
|
<>
|
||||||
|
<DateRelative value={changeRequest.updatedAt} /> by {author}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ToolbarBody>
|
||||||
|
|
||||||
|
<ToolbarSeparator />
|
||||||
|
|
||||||
|
<ToolbarButtonGroup>
|
||||||
|
{/* Refresh to retrieve latest changes */}
|
||||||
|
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
|
||||||
|
{/* Comment in app */}
|
||||||
|
<ToolbarButton
|
||||||
|
title="Comment in a GitBook"
|
||||||
|
href={getToolbarHref({
|
||||||
|
href: `${changeRequest.urls.app}~/comments`,
|
||||||
|
siteId: site.id,
|
||||||
|
buttonId: 'comment',
|
||||||
|
})}
|
||||||
|
icon="comment"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Open production site */}
|
||||||
|
{site.urls.published ? (
|
||||||
|
<ToolbarButton
|
||||||
|
title="Open production site"
|
||||||
|
href={getToolbarHref({
|
||||||
|
href: site.urls.published,
|
||||||
|
siteId: site.id,
|
||||||
|
buttonId: 'production-site',
|
||||||
|
})}
|
||||||
|
icon="globe"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Open CR in GitBook */}
|
||||||
|
<ToolbarButton
|
||||||
|
title="View change request in GitBook"
|
||||||
|
href={getToolbarHref({
|
||||||
|
href: changeRequest.urls.app,
|
||||||
|
siteId: site.id,
|
||||||
|
buttonId: 'change-request',
|
||||||
|
})}
|
||||||
|
icon="code-pull-request"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit in GitBook */}
|
||||||
|
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
|
||||||
|
</ToolbarButtonGroup>
|
||||||
|
</Toolbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RevisionToolbar(props: AdminToolbarClientProps) {
|
||||||
|
const { context } = props;
|
||||||
|
const { revision, site } = context;
|
||||||
|
if (!revision) {
|
||||||
|
throw new Error('Revision is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitURL = revision.git?.url;
|
||||||
|
const isGitHub = gitURL?.includes('github.com');
|
||||||
|
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toolbar>
|
||||||
|
<ToolbarBody>
|
||||||
|
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
|
||||||
|
<ToolbarSubtitle
|
||||||
|
subtitle={
|
||||||
|
<>
|
||||||
|
Created <DateRelative value={revision.createdAt} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ToolbarBody>
|
||||||
|
<ToolbarSeparator />
|
||||||
|
<ToolbarButtonGroup>
|
||||||
|
{/* Open commit in Git client */}
|
||||||
|
<ToolbarButton
|
||||||
|
title={
|
||||||
|
gitURL ? (
|
||||||
|
`Open commit in ${gitProvider}`
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
Setup GitSync to edit using Git{' '}
|
||||||
|
<div className="flex items-center gap-1 text-neutral-8 text-xs hover:text-neutral-6 hover:underline dark:text-neutral-3">
|
||||||
|
<a
|
||||||
|
href="https://gitbook.com/docs/getting-started/git-sync"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className=""
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</a>
|
||||||
|
<Icon icon="arrow-up-right" className="size-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
href={gitURL}
|
||||||
|
disabled={!gitURL}
|
||||||
|
icon={gitURL ? (isGitHub ? 'github' : 'gitlab') : 'github'}
|
||||||
|
/>
|
||||||
|
{site.urls.published ? (
|
||||||
|
<ToolbarButton
|
||||||
|
title="Open production site"
|
||||||
|
href={getToolbarHref({
|
||||||
|
href: site.urls.published,
|
||||||
|
siteId: site.id,
|
||||||
|
buttonId: 'production-site',
|
||||||
|
})}
|
||||||
|
icon="globe"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<ToolbarButton
|
||||||
|
title="View this revision in GitBook"
|
||||||
|
href={getToolbarHref({
|
||||||
|
href: revision.urls.app,
|
||||||
|
siteId: site.id,
|
||||||
|
buttonId: 'revision',
|
||||||
|
})}
|
||||||
|
icon="code-commit"
|
||||||
|
/>
|
||||||
|
</ToolbarButtonGroup>
|
||||||
|
</Toolbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
|
||||||
|
const { context } = props;
|
||||||
|
const { revision, space, site } = context;
|
||||||
|
const { refreshForUpdates, updated } = useCheckForContentUpdate({
|
||||||
|
revisionId: space.revision,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toolbar>
|
||||||
|
<ToolbarBody>
|
||||||
|
<ToolbarTitle prefix="Site" suffix={context.site.title} />
|
||||||
|
<ToolbarSubtitle
|
||||||
|
subtitle={
|
||||||
|
<>
|
||||||
|
Updated <DateRelative value={revision.createdAt} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ToolbarBody>
|
||||||
|
<ToolbarSeparator />
|
||||||
|
<ToolbarButtonGroup>
|
||||||
|
{/* Refresh to retrieve latest changes */}
|
||||||
|
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
|
||||||
|
<ToolbarButton
|
||||||
|
title="Open site in GitBook"
|
||||||
|
href={getToolbarHref({
|
||||||
|
href: site.urls.app,
|
||||||
|
siteId: site.id,
|
||||||
|
buttonId: 'site',
|
||||||
|
})}
|
||||||
|
icon="gear"
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
title="Customize in GitBook"
|
||||||
|
href={getToolbarHref({
|
||||||
|
href: `${site.urls.app}/customization/general`,
|
||||||
|
siteId: site.id,
|
||||||
|
buttonId: 'customize',
|
||||||
|
})}
|
||||||
|
icon="palette"
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
title="Open insights in GitBook"
|
||||||
|
href={getToolbarHref({
|
||||||
|
href: `${site.urls.app}/insights`,
|
||||||
|
siteId: site.id,
|
||||||
|
buttonId: 'insights',
|
||||||
|
})}
|
||||||
|
icon="chart-simple"
|
||||||
|
/>
|
||||||
|
<EditPageButton href={space.urls.app} siteId={site.id} />
|
||||||
|
</ToolbarButtonGroup>
|
||||||
|
</Toolbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditPageButton(props: {
|
||||||
|
href: string;
|
||||||
|
siteId: string;
|
||||||
|
motionValues?: ToolbarButtonProps['motionValues'];
|
||||||
|
}) {
|
||||||
|
const { href, motionValues, siteId } = props;
|
||||||
|
const pagePath = useCurrentPagePath();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolbarButton
|
||||||
|
title="Edit in GitBook"
|
||||||
|
href={getToolbarHref({
|
||||||
|
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
|
||||||
|
siteId,
|
||||||
|
buttonId: 'edit',
|
||||||
|
})}
|
||||||
|
icon="pencil"
|
||||||
|
motionValues={motionValues}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append utm parameters to a URL to track usage of the toolbar.
|
||||||
|
*/
|
||||||
|
function getToolbarHref({
|
||||||
|
href,
|
||||||
|
siteId,
|
||||||
|
buttonId,
|
||||||
|
}: { href: string; siteId: string; buttonId: string }) {
|
||||||
|
const url = new URL(href);
|
||||||
|
url.searchParams.set('utm_source', 'content');
|
||||||
|
url.searchParams.set('utm_medium', 'toolbar');
|
||||||
|
url.searchParams.set('utm_campaign', siteId);
|
||||||
|
url.searchParams.set('utm_content', buttonId);
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
.svgLogo {
|
||||||
|
--logo-fill: var(--color-neutral-100);
|
||||||
|
--seg-A-color: #2782c4;
|
||||||
|
--seg-B-color: #43b7f2;
|
||||||
|
--seg-C-color: #8be2ff;
|
||||||
|
|
||||||
|
--T: 2s;
|
||||||
|
shape-rendering: geometricPrecision;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.dark) .svgLogo {
|
||||||
|
--logo-fill: var(--color-neutral-800);
|
||||||
|
--seg-A-color: #19537d;
|
||||||
|
--seg-B-color: #2782c4;
|
||||||
|
--seg-C-color: #43b7f2;
|
||||||
|
--trace-color: #46474c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base segment animation */
|
||||||
|
.seg {
|
||||||
|
opacity: 0;
|
||||||
|
animation: segFade var(--T) ease both 0.06s, segAMove var(--T) linear,
|
||||||
|
segALen var(--T) linear;
|
||||||
|
animation-delay: 0.06s;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segA {
|
||||||
|
animation-name: segFade, segAMove, segALen;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segB {
|
||||||
|
animation-name: segFade, segBMove, segBLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segC {
|
||||||
|
animation-name: segFade, segCMove, segCLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace {
|
||||||
|
animation: traceFill var(--T) ease both 0.06s;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes traceFill {
|
||||||
|
0% {
|
||||||
|
fill: transparent;
|
||||||
|
stroke:var(--trace-color);
|
||||||
|
}
|
||||||
|
95% {
|
||||||
|
fill: transparent;
|
||||||
|
stroke:var(--trace-color);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
fill: var(--logo-fill);
|
||||||
|
stroke:none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Segment A animation */
|
||||||
|
@keyframes segAMove {
|
||||||
|
0% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
stroke: var(--seg-A-color);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
stroke-dashoffset: -0.5;
|
||||||
|
stroke: var(--seg-A-color);
|
||||||
|
}
|
||||||
|
95% {
|
||||||
|
stroke-dashoffset: -1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes segALen {
|
||||||
|
0% {
|
||||||
|
stroke-dasharray: 0.18 0.82;
|
||||||
|
}
|
||||||
|
95% {
|
||||||
|
stroke-dasharray: 0.22 0.78;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
stroke-dasharray: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segment B animation */
|
||||||
|
@keyframes segBMove {
|
||||||
|
0% {
|
||||||
|
stroke-dashoffset: -0.18;
|
||||||
|
stroke: var(--seg-B-color);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
stroke-dashoffset: -0.72;
|
||||||
|
stroke: var(--seg-B-color);
|
||||||
|
}
|
||||||
|
95% {
|
||||||
|
stroke-dashoffset: -1.18;
|
||||||
|
stroke: var(--seg-B-color);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes segBLen {
|
||||||
|
0%,
|
||||||
|
50%,
|
||||||
|
95% {
|
||||||
|
stroke-dasharray: 0.2 0.8;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
stroke-dasharray: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segment C animation */
|
||||||
|
@keyframes segCMove {
|
||||||
|
0% {
|
||||||
|
stroke-dashoffset: -0.38;
|
||||||
|
stroke: var(--seg-C-color);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
stroke-dashoffset: -0.92;
|
||||||
|
stroke: var(--seg-C-color);
|
||||||
|
}
|
||||||
|
95% {
|
||||||
|
stroke-dashoffset: -1.38;
|
||||||
|
stroke: var(--seg-C-color);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes segCLen {
|
||||||
|
0% {
|
||||||
|
stroke-dasharray: 0.22 0.78;
|
||||||
|
}
|
||||||
|
95% {
|
||||||
|
stroke-dasharray: 0.18 0.82;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
stroke-dasharray: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segment fade animation - stays visible at end */
|
||||||
|
@keyframes segFade {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion support */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.seg {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.trace {
|
||||||
|
animation: none;
|
||||||
|
fill: var(--logo-fill);
|
||||||
|
stroke:none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
import type React from 'react';
|
||||||
|
import styles from './AnimatedLogo.module.css';
|
||||||
|
|
||||||
|
export const AnimatedLogo: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={styles.svgLogo}
|
||||||
|
width="28"
|
||||||
|
height="28"
|
||||||
|
viewBox="0 0 28 28"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{/* Background trace */}
|
||||||
|
<path
|
||||||
|
d={dPath}
|
||||||
|
transform="translate(14,14) scale(0.4)"
|
||||||
|
strokeWidth={3}
|
||||||
|
pathLength={1}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className={styles.trace}
|
||||||
|
/>
|
||||||
|
{/* Three individual paths to create the 'chasing' segmented stroke effect */}
|
||||||
|
<path
|
||||||
|
className={tcls(styles.seg, styles.segA)}
|
||||||
|
d={dPath}
|
||||||
|
transform="translate(14,14) scale(0.4)"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth={3}
|
||||||
|
pathLength={1}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray="0.18 0.82"
|
||||||
|
strokeDashoffset={0}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className={tcls(styles.seg, styles.segB)}
|
||||||
|
d={dPath}
|
||||||
|
transform="translate(14,14) scale(0.4)"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth={3}
|
||||||
|
pathLength={1}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray="0.20 0.80"
|
||||||
|
strokeDashoffset={-0.18}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className={tcls(styles.seg, styles.segC)}
|
||||||
|
d={dPath}
|
||||||
|
transform="translate(14,14) scale(0.4)"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth={3}
|
||||||
|
pathLength={1}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray="0.22 0.78"
|
||||||
|
strokeDashoffset={-0.38}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dPath =
|
||||||
|
'M-5.07306 1.64898C-1.92626 3.46518 -0.352865 4.37328 1.37504 4.37477C3.10303 4.37628 4.67794 3.47098 7.82794 1.66027C7.82794 1.66027 27.9071 -9.88183 27.9071 -9.88183C28.8136 -10.4029 29.3724 -11.3687 29.3724 -12.4143C29.3724 -13.4598 28.8136 -14.4257 27.9071 -14.9467C27.9071 -14.9467 7.82063 -26.4931 7.82063 -26.4931C4.67414 -28.3018 3.10083 -29.2062 1.37453 -29.2055C-0.351665 -29.2048 -1.92427 -28.2992 -5.06947 -26.488C-5.06947 -26.488 -22.3372 -16.5441 -22.3372 -16.5441C-22.4651 -16.4704 -22.5291 -16.4336 -22.5888 -16.3986C-28.4872 -12.9457 -32.1315 -6.64173 -32.1802 0.192975C-32.1807 0.262075 -32.1807 0.335975 -32.1807 0.483575C-32.1807 0.631075 -32.1807 0.704875 -32.1802 0.773875C-32.1316 7.60087 -28.4955 13.899 -22.6075 17.3547C-22.548 17.3897 -22.4841 17.4266 -22.3564 17.5003C-22.3564 17.5003 -11.5399 23.7454 -11.5399 23.7454C-5.23716 27.3844 -2.08587 29.2039 1.37484 29.2051C4.83554 29.2062 7.98813 27.3889 14.2932 23.7541C14.2932 23.7541 25.7115 17.1718 25.7115 17.1718C28.8686 15.3518 30.4471 14.4418 31.3139 12.9416C32.1807 11.4414 32.1807 9.61938 32.1807 5.97517C32.1807 5.97517 32.1807 -1.06463 32.1807 -1.06463C32.1807 -2.07553 31.6332 -3.00723 30.75 -3.49913C29.8953 -3.97513 28.8536 -3.96802 28.0054 -3.48053C28.0054 -3.48053 4.59224 9.97808 4.59224 9.97808C3.02144 10.8811 2.23593 11.3326 1.37404 11.3329C0.512135 11.3331 -0.273565 10.8821 -1.84506 9.98007C-1.84506 9.98007 -17.6916 0.883775 -17.6916 0.883775C-18.4854 0.428075 -18.8823 0.200275 -19.2011 0.159175C-19.9279 0.065375 -20.6267 0.472475 -20.9036 1.15108C-21.025 1.44858 -21.0225 1.90628 -21.0176 2.82148C-21.014 3.49528 -21.0122 3.83218 -20.9492 4.14208C-20.8082 4.83607 -20.4431 5.46448 -19.91 5.93068C-19.672 6.13888 -19.3802 6.30727 -18.7966 6.64407C-18.7966 6.64407 -1.85397 16.4227 -1.85397 16.4227C-0.278465 17.332 0.509335 17.7867 1.37434 17.7869C2.23934 17.7872 3.02734 17.3329 4.60333 16.4245C4.60333 16.4245 25.3699 4.45377 25.3699 4.45377C25.9083 4.14348 26.1774 3.98828 26.3792 4.10488C26.5811 4.22148 26.5811 4.53218 26.5811 5.15357C26.5811 5.15357 26.5811 8.34667 26.5811 8.34667C26.5811 9.25768 26.5811 9.71317 26.3643 10.0883C26.1476 10.4634 25.753 10.6908 24.9637 11.1458C24.9637 11.1458 7.83524 21.0193 7.83524 21.0193C4.68194 22.837 3.10533 23.7458 1.37463 23.7451C-0.356065 23.7443 -1.93186 22.834 -5.08347 21.0134C-5.08347 21.0134 -21.1086 11.7563 -21.1086 11.7563C-21.1595 11.7269 -21.1849 11.7122 -21.2087 11.6984C-24.5687 9.73487 -26.642 6.14287 -26.6614 2.25128C-26.6616 2.22378 -26.6616 2.19438 -26.6616 2.13567C-26.6616 2.13567 -26.6616 -0.795425 -26.6616 -0.795425C-26.6616 -2.94372 -25.5174 -4.92953 -23.6587 -6.00682C-22.0163 -6.95883 -19.9905 -6.96072 -18.3463 -6.01183C-18.3463 -6.01183 -5.07306 1.64898 -5.07306 1.64898Z';
|
||||||
@@ -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,70 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import { Icon } from '@gitbook/icons';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { useCheckForContentUpdate } from '@/components/AutoRefreshContent';
|
|
||||||
import { tcls } from '@/lib/tailwind';
|
|
||||||
|
|
||||||
import { ToolbarButton } from './Toolbar';
|
|
||||||
|
|
||||||
// We don't show the button if the content has been updated 30s ago or less.
|
|
||||||
const minInterval = 1000 * 30; // 5 minutes
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Button to refresh the page if the content has been updated.
|
|
||||||
*/
|
|
||||||
export function RefreshChangeRequestButton(props: {
|
|
||||||
spaceId: string;
|
|
||||||
changeRequestId: string;
|
|
||||||
revisionId: string;
|
|
||||||
updatedAt: number;
|
|
||||||
}) {
|
|
||||||
const { updatedAt } = props;
|
|
||||||
|
|
||||||
const [visible, setVisible] = React.useState(false);
|
|
||||||
const [loading, setLoading] = React.useState(false);
|
|
||||||
const checkForUpdates = useCheckForContentUpdate(props);
|
|
||||||
|
|
||||||
const refresh = React.useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await checkForUpdates();
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setVisible(false);
|
|
||||||
}
|
|
||||||
}, [checkForUpdates]);
|
|
||||||
|
|
||||||
// Show the button if the content has been updated more than 30s ago.
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (updatedAt < Date.now() - minInterval) {
|
|
||||||
setVisible(true);
|
|
||||||
}
|
|
||||||
}, [updatedAt]);
|
|
||||||
|
|
||||||
// 30sec after being hidden, we show the button again
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!visible) {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
setVisible(true);
|
|
||||||
}, minInterval);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
if (!visible) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToolbarButton
|
|
||||||
title="Refresh"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
refresh();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="rotate" className={tcls('size-4', loading ? 'animate-spin' : null)} />
|
|
||||||
</ToolbarButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button to refresh the page if the content has been updated.
|
||||||
|
*/
|
||||||
|
export function RefreshContentButton(props: {
|
||||||
|
refreshForUpdates: () => Promise<void>;
|
||||||
|
motionValues?: ToolbarButtonProps['motionValues'];
|
||||||
|
}) {
|
||||||
|
const { refreshForUpdates, motionValues } = props;
|
||||||
|
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
|
||||||
|
const refresh = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await refreshForUpdates();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [refreshForUpdates]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolbarButton
|
||||||
|
title="Refresh for latest changes"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
motionValues={motionValues}
|
||||||
|
icon="rotate"
|
||||||
|
iconClassName={loading ? 'animate-spin' : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,65 +1,288 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { AnimatePresence, type MotionValue, motion, useReducedMotion } from 'motion/react';
|
||||||
import type * as React from 'react';
|
import React from 'react';
|
||||||
|
import { AnimatedLogo } from './AnimatedLogo';
|
||||||
|
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
import { Icon, type IconName } from '@gitbook/icons';
|
||||||
|
import { Tooltip } from '../primitives';
|
||||||
|
import { getCopyVariants, minifyButtonAnimation, toolbarEasings } from './transitions';
|
||||||
|
import { useMagnificationEffect } from './useMagnificationEffect';
|
||||||
|
|
||||||
|
const DURATION_LOGO_APPEARANCE = 2000;
|
||||||
|
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
|
||||||
|
|
||||||
export function Toolbar(props: { children: React.ReactNode }) {
|
export function Toolbar(props: { children: React.ReactNode }) {
|
||||||
const { children } = props;
|
const [minified, setMinified] = React.useState(true);
|
||||||
|
const [showToolbarControls, setShowToolbarControls] = React.useState(false);
|
||||||
|
const [isReady, setIsReady] = React.useState(false);
|
||||||
|
|
||||||
|
// Wait for page to be ready, then show the toolbar
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleLoad = () => {
|
||||||
|
setIsReady(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
handleLoad();
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', handleLoad);
|
||||||
|
return () => window.removeEventListener('load', handleLoad);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// After toolbar appears, wait then show the full content
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isReady) {
|
||||||
|
const expandAfterTimeout = setTimeout(() => {
|
||||||
|
setMinified(false);
|
||||||
|
}, DURATION_LOGO_APPEARANCE + DELAY_BETWEEN_LOGO_AND_CONTENT);
|
||||||
|
|
||||||
|
return () => clearTimeout(expandAfterTimeout);
|
||||||
|
}
|
||||||
|
}, [isReady]);
|
||||||
|
|
||||||
|
// Don't render anything until page is ready
|
||||||
|
if (!isReady) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<motion.div
|
||||||
className={tcls(
|
onMouseEnter={() => setShowToolbarControls(true)}
|
||||||
'flex',
|
onMouseLeave={() => setShowToolbarControls(false)}
|
||||||
'flex-row',
|
className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4"
|
||||||
'items-center',
|
|
||||||
'gap-4',
|
|
||||||
'text-sm',
|
|
||||||
'px-4',
|
|
||||||
'py-1',
|
|
||||||
'rounded-full',
|
|
||||||
'truncate',
|
|
||||||
'text-tint-1',
|
|
||||||
'dark:text-tint-12'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{children}
|
<AnimatePresence mode="wait">
|
||||||
</div>
|
<motion.div
|
||||||
|
onClick={() => {
|
||||||
|
if (minified) {
|
||||||
|
setMinified((prev) => !prev);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
layout
|
||||||
|
transition={toolbarEasings.spring}
|
||||||
|
className={tcls(
|
||||||
|
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
|
||||||
|
'flex',
|
||||||
|
'items-center',
|
||||||
|
'justify-center',
|
||||||
|
'min-h-11',
|
||||||
|
'min-w-12',
|
||||||
|
'h-12',
|
||||||
|
'py-2',
|
||||||
|
'border-tint-1/3',
|
||||||
|
'backdrop-blur-sm',
|
||||||
|
'origin-center',
|
||||||
|
'bg-[linear-gradient(110deg,rgba(20,23,28,0.90)_0%,rgba(20,23,28,0.80)_100%)]',
|
||||||
|
'dark:bg-[linear-gradient(110deg,rgba(256,256,256,0.90)_0%,rgba(256,256,256,0.80)_100%)]'
|
||||||
|
)}
|
||||||
|
initial={{
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
boxShadow: minified
|
||||||
|
? '0 4px 40px 8px rgba(0, 0, 0, .2), 0 0 0 .5px rgba(0, 0, 0, .4), inset 0 .5px 0 0 hsla(0, 0%, 100%, .15)'
|
||||||
|
: '0 4px 40px 8px rgba(0, 0, 0, .4), 0 0 0 .5px rgba(0, 0, 0, .8), inset 0 .5px 0 0 hsla(0, 0%, 100%, .3)',
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo with stroke segments animation in blue-tints */}
|
||||||
|
<motion.div layout>
|
||||||
|
<AnimatedLogo />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{!minified ? props.children : null}
|
||||||
|
|
||||||
|
{!minified && showToolbarControls && <MinifyButton setMinified={setMinified} />}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolbarBody(props: { children: React.ReactNode }) {
|
export function ToolbarBody(props: { children: React.ReactNode }) {
|
||||||
return <div className="flex flex-col gap-1">{props.children}</div>;
|
return <div className="flex flex-col items-start px-3">{props.children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolbarButtonGroups(props: { children: React.ReactNode }) {
|
export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
|
||||||
return <div className="flex flex-row gap-2">{props.children}</div>;
|
const { children } = props;
|
||||||
}
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const buttonChildren = React.Children.toArray(children).filter((child) => !!child);
|
||||||
|
const { buttonMotionValues } = useMagnificationEffect({
|
||||||
|
childrenCount: buttonChildren.length,
|
||||||
|
containerRef,
|
||||||
|
});
|
||||||
|
|
||||||
export function ToolbarButton(props: React.HTMLProps<HTMLAnchorElement>) {
|
|
||||||
const { children, ...rest } = props;
|
|
||||||
return (
|
return (
|
||||||
<a
|
<motion.div
|
||||||
{...rest}
|
ref={containerRef}
|
||||||
className={tcls(
|
variants={toolbarEasings.parent}
|
||||||
'flex',
|
initial="hidden"
|
||||||
'flex-col',
|
animate="show"
|
||||||
'items-center',
|
className="flex items-center gap-1 overflow-visible pr-2 pl-4"
|
||||||
'justify-center',
|
|
||||||
'size-11',
|
|
||||||
'gap-1',
|
|
||||||
'text-sm',
|
|
||||||
'rounded-full',
|
|
||||||
'truncate',
|
|
||||||
'text-tint-1', // Equal to text-tint-strong in dark mode
|
|
||||||
'dark:text-tint-12', // Equal to text-tint-strong in dark mode
|
|
||||||
'hover:bg-tint-12',
|
|
||||||
'dark:hover:bg-tint-1',
|
|
||||||
'hover:shadow-lg',
|
|
||||||
'cursor-pointer'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{children}
|
{buttonChildren.map((child, index) => {
|
||||||
</a>
|
const motionValues = buttonMotionValues[index];
|
||||||
|
const childEl = child as React.ReactElement;
|
||||||
|
return React.cloneElement(childEl, {
|
||||||
|
key: index,
|
||||||
|
motionValues,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorElement>, 'title'> {
|
||||||
|
motionValues?: {
|
||||||
|
scale: MotionValue<number>;
|
||||||
|
x: MotionValue<number>;
|
||||||
|
};
|
||||||
|
icon: IconName;
|
||||||
|
iconClassName?: string;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolbarButton(props: ToolbarButtonProps) {
|
||||||
|
const { title, disabled, motionValues, className, style, href, onClick, icon, iconClassName } =
|
||||||
|
props;
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div variants={toolbarEasings.staggeringChild}>
|
||||||
|
<Tooltip label={title}>
|
||||||
|
<motion.a
|
||||||
|
href={href}
|
||||||
|
onClick={onClick}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={
|
||||||
|
reduceMotion
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
scale: motionValues?.scale,
|
||||||
|
x: motionValues?.x,
|
||||||
|
transformOrigin: 'bottom center',
|
||||||
|
zIndex: motionValues?.scale ? 10 : 'auto',
|
||||||
|
...style,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 400,
|
||||||
|
damping: 30,
|
||||||
|
}}
|
||||||
|
className={tcls(
|
||||||
|
'toolbar-button',
|
||||||
|
className,
|
||||||
|
'flex',
|
||||||
|
'relative',
|
||||||
|
'items-center',
|
||||||
|
'justify-center',
|
||||||
|
'gap-1',
|
||||||
|
'text-sm',
|
||||||
|
'rounded-full',
|
||||||
|
'border-neutral-500',
|
||||||
|
'outline-neutral-800',
|
||||||
|
'outline-1',
|
||||||
|
'border',
|
||||||
|
'truncate',
|
||||||
|
'text-tint-1',
|
||||||
|
'dark:text-tint-12',
|
||||||
|
'cursor-pointer',
|
||||||
|
'transition-colors',
|
||||||
|
'size-8',
|
||||||
|
'bg-tint-1/3',
|
||||||
|
'hover:bg-tint-1/4',
|
||||||
|
'dark:bg-tint-3',
|
||||||
|
'dark:hover:bg-tint-1',
|
||||||
|
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||||
|
'shadow-1xs'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon={icon} className={tcls('size-4', iconClassName)} />
|
||||||
|
</motion.a>
|
||||||
|
</Tooltip>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolbarSeparator() {
|
||||||
|
return <div className="h-5 w-px bg-tint-1/3" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolbarTitle(props: { prefix: string; suffix: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 text-xs ">
|
||||||
|
<ToolbarTitlePrefix title={props.prefix} />
|
||||||
|
<ToolbarTitleSuffix title={props.suffix} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarTitlePrefix(props: { title: string }) {
|
||||||
|
return (
|
||||||
|
<motion.span
|
||||||
|
{...getCopyVariants(0)}
|
||||||
|
className="font-light text-neutral-7 dark:text-neutral-3"
|
||||||
|
>
|
||||||
|
{props.title}
|
||||||
|
</motion.span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarTitleSuffix(props: { title: string }) {
|
||||||
|
return (
|
||||||
|
<motion.span
|
||||||
|
{...getCopyVariants(1)}
|
||||||
|
className="max-w-[24ch] truncate font-semibold text-neutral-3 dark:text-neutral-2"
|
||||||
|
>
|
||||||
|
{props.title}
|
||||||
|
</motion.span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<motion.span
|
||||||
|
{...getCopyVariants(1)}
|
||||||
|
className="text-neutral-7 text-xxs dark:text-neutral-2"
|
||||||
|
>
|
||||||
|
{props.subtitle}
|
||||||
|
</motion.span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MinifyButton(props: { setMinified: (minified: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<Tooltip label="Minify">
|
||||||
|
<motion.div
|
||||||
|
{...minifyButtonAnimation}
|
||||||
|
transition={{
|
||||||
|
duration: 0.2,
|
||||||
|
}}
|
||||||
|
whileHover={{
|
||||||
|
scale: 1.05,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
props.setMinified(true);
|
||||||
|
}}
|
||||||
|
className={tcls(
|
||||||
|
'-top-2 -right-4 absolute flex size-4 cursor-pointer items-center justify-center rounded-full border',
|
||||||
|
'border-neutral-500 bg-neutral-700 hover:border-neutral-400 hover:bg-neutral-600',
|
||||||
|
'dark:border-neutral-400 dark:bg-neutral-200 dark:hover:border-neutral-200 dark:hover:bg-neutral-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon="minus" className="size-2 text-neutral-1 dark:text-neutral-9" />
|
||||||
|
</motion.div>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
export * from './AdminToolbar';
|
export * from './AdminToolbar';
|
||||||
|
export * from './AdminToolbarClient';
|
||||||
|
export * from './IframeWrapper';
|
||||||
|
export * from './Toolbar';
|
||||||
|
export * from './transitions';
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Transition } from 'motion/react';
|
||||||
|
|
||||||
|
// Slight bounce but minimal overshoot esp. at the minimized state.
|
||||||
|
const spring = {
|
||||||
|
type: 'spring' as const,
|
||||||
|
stiffness: 130,
|
||||||
|
damping: 19,
|
||||||
|
mass: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const parent = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
delayChildren: 0.4,
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const staggeringChild = {
|
||||||
|
hidden: {
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.7,
|
||||||
|
},
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
type: 'spring',
|
||||||
|
} as Transition,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const minifyButtonAnimation = {
|
||||||
|
initial: {
|
||||||
|
scale: 0.5,
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
scale: 0.5,
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCopyVariants = (position: number) => {
|
||||||
|
return {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
x: -10,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
},
|
||||||
|
transition: {
|
||||||
|
duration: 0.1,
|
||||||
|
delay: position / 10 + 0.3,
|
||||||
|
} as Transition,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toolbarEasings = {
|
||||||
|
spring,
|
||||||
|
parent,
|
||||||
|
staggeringChild,
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* Minimal types containing only the fields needed for AdminToolbar to restrict what gets serialized
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MinimalChangeRequest = {
|
||||||
|
id: string;
|
||||||
|
number: number;
|
||||||
|
subject: string | null;
|
||||||
|
revision: string;
|
||||||
|
updatedAt: string;
|
||||||
|
createdBy: {
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
urls: {
|
||||||
|
app: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MinimalRevision = {
|
||||||
|
createdAt: string;
|
||||||
|
urls: {
|
||||||
|
app: string;
|
||||||
|
};
|
||||||
|
git?: {
|
||||||
|
url: string | undefined;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MinimalSpace = {
|
||||||
|
id: string;
|
||||||
|
revision: string;
|
||||||
|
urls: {
|
||||||
|
app: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MinimalSite = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
urls: {
|
||||||
|
app: string;
|
||||||
|
published: string | undefined;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminToolbarContext = {
|
||||||
|
organizationId: string;
|
||||||
|
revisionId: string;
|
||||||
|
space: MinimalSpace;
|
||||||
|
changeRequest: MinimalChangeRequest | null;
|
||||||
|
revision: MinimalRevision;
|
||||||
|
site: MinimalSite;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AdminToolbarClientProps {
|
||||||
|
context: AdminToolbarContext;
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { type MotionValue, motionValue } from 'framer-motion';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ButtonMotionValues {
|
||||||
|
scale: MotionValue<number>;
|
||||||
|
x: MotionValue<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MagnificationConfig {
|
||||||
|
/** Size of each button in pixels - used for spacing calculations */
|
||||||
|
buttonSize?: number;
|
||||||
|
/** Maximum scale factor for buttons when mouse is directly over them */
|
||||||
|
maxScale?: number;
|
||||||
|
/** Distance in pixels from mouse where buttons start scaling */
|
||||||
|
influenceRadius?: number;
|
||||||
|
/** Multiplier for spacing between buttons - higher values create more space */
|
||||||
|
spacingMultiplier?: number;
|
||||||
|
/** Controls scaling curve - higher values make scaling more dramatic near mouse */
|
||||||
|
scaleExponent?: number;
|
||||||
|
/** Padding around container for mouse detection in pixels */
|
||||||
|
padding?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultConfig: Required<MagnificationConfig> = {
|
||||||
|
buttonSize: 32, // Standard button size for 32px (size-8) buttons
|
||||||
|
maxScale: 1.3, // 30% scale increase - noticeable but not overwhelming
|
||||||
|
influenceRadius: 80, // ~2.5 button widths of influence
|
||||||
|
spacingMultiplier: 1.3, // Creates proportional spacing to scale increase
|
||||||
|
scaleExponent: 2.5, // Exponential curve for dramatic close-range scaling
|
||||||
|
padding: 10, // Small buffer zone around container edges
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions for cleaner code
|
||||||
|
const createMotionValues = (count: number): ButtonMotionValues[] =>
|
||||||
|
Array.from({ length: count }, () => ({
|
||||||
|
scale: motionValue(1),
|
||||||
|
x: motionValue(0),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resetMotionValues = (motionValues: ButtonMotionValues[]) => {
|
||||||
|
motionValues.forEach(({ scale, x }) => {
|
||||||
|
scale.set(1);
|
||||||
|
x.set(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const captureButtonPositions = (buttons: HTMLElement[]) => {
|
||||||
|
// Reset transforms to get original positions
|
||||||
|
buttons.forEach((button) => (button.style.transform = ''));
|
||||||
|
return buttons.map((button) => {
|
||||||
|
const rect = button.getBoundingClientRect();
|
||||||
|
return { left: rect.left, width: rect.width };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateScale = (
|
||||||
|
mouseX: number,
|
||||||
|
buttonCenterX: number,
|
||||||
|
containerRect: DOMRect,
|
||||||
|
config: Required<MagnificationConfig>
|
||||||
|
) => {
|
||||||
|
const distance = Math.abs(mouseX - buttonCenterX);
|
||||||
|
|
||||||
|
if (distance > config.influenceRadius) return 1;
|
||||||
|
|
||||||
|
// Edge influence to reduce scaling near container edges
|
||||||
|
const distanceFromEdge = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
|
||||||
|
const edgeInfluence = Math.min(distanceFromEdge / 20, 1);
|
||||||
|
|
||||||
|
// Exponential scaling curve
|
||||||
|
const progress = distance / config.influenceRadius;
|
||||||
|
const exponentialProgress = (1 - progress) ** config.scaleExponent;
|
||||||
|
const baseScale = 1 + (config.maxScale - 1) * exponentialProgress;
|
||||||
|
|
||||||
|
return 1 + (baseScale - 1) * edgeInfluence;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateSpacing = (
|
||||||
|
buttonIndex: number,
|
||||||
|
buttonEffects: Array<{ scale: number; translateX: number }>,
|
||||||
|
positions: Array<{ left: number; width: number }>,
|
||||||
|
config: Required<MagnificationConfig>
|
||||||
|
) => {
|
||||||
|
const currentPos = positions[buttonIndex];
|
||||||
|
if (!currentPos) return 0;
|
||||||
|
|
||||||
|
const buttonCenterX = currentPos.left + currentPos.width / 2;
|
||||||
|
let leftPush = 0;
|
||||||
|
let rightPush = 0;
|
||||||
|
|
||||||
|
buttonEffects.forEach((otherEffect, otherIndex) => {
|
||||||
|
if (otherIndex === buttonIndex) return;
|
||||||
|
|
||||||
|
const otherPos = positions[otherIndex];
|
||||||
|
if (!otherPos) return;
|
||||||
|
|
||||||
|
const otherCenterX = otherPos.left + otherPos.width / 2;
|
||||||
|
const distanceBetween = Math.abs(buttonCenterX - otherCenterX);
|
||||||
|
|
||||||
|
// Only apply influence if close enough
|
||||||
|
if (distanceBetween < config.influenceRadius * 1.5) {
|
||||||
|
const influenceStrength = Math.max(
|
||||||
|
0,
|
||||||
|
1 - distanceBetween / (config.influenceRadius * 1.5)
|
||||||
|
);
|
||||||
|
const extraSpace = (otherEffect.scale - 1) * config.buttonSize;
|
||||||
|
const pushAmount = (extraSpace / 2) * config.spacingMultiplier * influenceStrength;
|
||||||
|
|
||||||
|
if (otherCenterX < buttonCenterX) {
|
||||||
|
rightPush += pushAmount;
|
||||||
|
} else {
|
||||||
|
leftPush += pushAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return rightPush - leftPush;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMagnificationEffect(props: {
|
||||||
|
childrenCount: number;
|
||||||
|
containerRef: React.RefObject<HTMLElement>;
|
||||||
|
config?: MagnificationConfig;
|
||||||
|
}) {
|
||||||
|
const { childrenCount, containerRef, config } = props;
|
||||||
|
const [buttonMotionValues, setButtonMotionValues] = React.useState<ButtonMotionValues[]>([]);
|
||||||
|
const originalPositionsRef = React.useRef<Array<{ left: number; width: number }>>([]);
|
||||||
|
|
||||||
|
const finalConfig = { ...defaultConfig, ...config };
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const buttons = Array.from(container.querySelectorAll('.toolbar-button')) as HTMLElement[];
|
||||||
|
|
||||||
|
if (buttons.length !== childrenCount) {
|
||||||
|
console.error(
|
||||||
|
`Button count (${buttons.length}) does not match children count (${childrenCount})`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize motion values if button count changed
|
||||||
|
if (buttonMotionValues.length !== buttons.length) {
|
||||||
|
setButtonMotionValues(createMotionValues(buttons.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
|
const buttons = Array.from(
|
||||||
|
container.querySelectorAll('.toolbar-button')
|
||||||
|
) as HTMLElement[];
|
||||||
|
|
||||||
|
// Capture positions if needed
|
||||||
|
if (originalPositionsRef.current.length !== buttons.length) {
|
||||||
|
originalPositionsRef.current = captureButtonPositions(buttons);
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const { clientX: mouseX, clientY: mouseY } = event;
|
||||||
|
|
||||||
|
// Check if mouse is in range
|
||||||
|
const isInRange =
|
||||||
|
mouseX >= containerRect.left - finalConfig.padding &&
|
||||||
|
mouseX <= containerRect.right + finalConfig.padding &&
|
||||||
|
mouseY >= containerRect.top - finalConfig.padding &&
|
||||||
|
mouseY <= containerRect.bottom + finalConfig.padding;
|
||||||
|
|
||||||
|
if (!isInRange) {
|
||||||
|
resetMotionValues(buttonMotionValues);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate scales for all buttons
|
||||||
|
const buttonEffects = buttons.map((_, index) => {
|
||||||
|
const pos = originalPositionsRef.current[index];
|
||||||
|
if (!pos) return { scale: 1, translateX: 0 };
|
||||||
|
|
||||||
|
const buttonCenterX = pos.left + pos.width / 2;
|
||||||
|
const scale = calculateScale(mouseX, buttonCenterX, containerRect, finalConfig);
|
||||||
|
return { scale, translateX: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate spacing for all buttons
|
||||||
|
buttonEffects.forEach((effect, index) => {
|
||||||
|
effect.translateX = calculateSpacing(
|
||||||
|
index,
|
||||||
|
buttonEffects,
|
||||||
|
originalPositionsRef.current,
|
||||||
|
finalConfig
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update motion values
|
||||||
|
buttonEffects.forEach((effect, index) => {
|
||||||
|
const motionValue = buttonMotionValues[index];
|
||||||
|
if (motionValue) {
|
||||||
|
motionValue.scale.set(effect.scale);
|
||||||
|
motionValue.x.set(effect.translateX);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => resetMotionValues(buttonMotionValues);
|
||||||
|
|
||||||
|
container.addEventListener('mousemove', handleMouseMove);
|
||||||
|
container.addEventListener('mouseleave', handleMouseLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
container.removeEventListener('mouseleave', handleMouseLeave);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
finalConfig.buttonSize,
|
||||||
|
finalConfig.maxScale,
|
||||||
|
finalConfig.influenceRadius,
|
||||||
|
finalConfig.spacingMultiplier,
|
||||||
|
finalConfig.scaleExponent,
|
||||||
|
finalConfig.padding,
|
||||||
|
containerRef,
|
||||||
|
buttonMotionValues,
|
||||||
|
childrenCount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { buttonMotionValues };
|
||||||
|
}
|
||||||
@@ -34,69 +34,67 @@ export function AnnouncementBanner(props: {
|
|||||||
className="theme-bold:bg-header-background pt-4 pb-2"
|
className="theme-bold:bg-header-background pt-4 pb-2"
|
||||||
data-nosnippet=""
|
data-nosnippet=""
|
||||||
>
|
>
|
||||||
<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('relative', CONTAINER_STYLE)}>
|
||||||
<div className={tcls('relative', CONTAINER_STYLE)}>
|
<Tag
|
||||||
<Tag
|
href={contentRef?.href ?? ''}
|
||||||
href={contentRef?.href ?? ''}
|
className={tcls(
|
||||||
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',
|
||||||
'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,
|
||||||
style.container,
|
closeable && 'pr-12',
|
||||||
closeable && 'pr-12',
|
hasLink && style.hover
|
||||||
hasLink && style.hover
|
)}
|
||||||
)}
|
insights={
|
||||||
insights={
|
announcement.link
|
||||||
announcement.link
|
? {
|
||||||
? {
|
type: 'link_click',
|
||||||
type: 'link_click',
|
link: {
|
||||||
link: {
|
target: announcement.link.to,
|
||||||
target: announcement.link.to,
|
position: SiteInsightsLinkPosition.Announcement,
|
||||||
position: SiteInsightsLinkPosition.Announcement,
|
},
|
||||||
},
|
}
|
||||||
}
|
: undefined
|
||||||
: undefined
|
}
|
||||||
}
|
>
|
||||||
>
|
<Icon
|
||||||
<Icon
|
icon={style.icon as IconName}
|
||||||
icon={style.icon as IconName}
|
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
|
||||||
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
|
/>
|
||||||
/>
|
<div>
|
||||||
<div>
|
{announcement.message}
|
||||||
{announcement.message}
|
{hasLink ? (
|
||||||
{hasLink ? (
|
<div className={tcls(LinkStyles, style.link, 'ml-1 inline')}>
|
||||||
<div className={tcls(LinkStyles, style.link, 'ml-1 inline')}>
|
{contentRef?.icon ? (
|
||||||
{contentRef?.icon ? (
|
<span className="mr-1 ml-2 *:inline">
|
||||||
<span className="mr-1 ml-2 *:inline">
|
{contentRef?.icon}
|
||||||
{contentRef?.icon}
|
</span>
|
||||||
</span>
|
) : null}
|
||||||
) : null}
|
{announcement.link?.title && (
|
||||||
{announcement.link?.title && (
|
<span className="mr-1">{announcement.link?.title}</span>
|
||||||
<span className="mr-1">{announcement.link?.title}</span>
|
)}
|
||||||
)}
|
<Icon
|
||||||
<Icon
|
icon={
|
||||||
icon={
|
announcement.link?.to.kind === 'url'
|
||||||
announcement.link?.to.kind === 'url'
|
? 'arrow-up-right'
|
||||||
? 'arrow-up-right'
|
: 'chevron-right'
|
||||||
: 'chevron-right'
|
}
|
||||||
}
|
className={tcls('mb-0.5 inline size-3')}
|
||||||
className={tcls('mb-0.5 inline size-3')}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</Tag>
|
||||||
</Tag>
|
{closeable ? (
|
||||||
{closeable ? (
|
<Button
|
||||||
<Button
|
iconOnly
|
||||||
iconOnly
|
icon="close"
|
||||||
icon="close"
|
label={tString(language, 'close')}
|
||||||
label={tString(language, 'close')}
|
variant="blank"
|
||||||
variant="blank"
|
size="default"
|
||||||
size="default"
|
onClick={dismissAnnouncement}
|
||||||
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}`}
|
||||||
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}
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,28 +1,43 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { getDataOrNull } from '@/lib/data';
|
import { getDataOrNull } from '@/lib/data';
|
||||||
|
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||||
import { traceErrorOnly } from '@/lib/tracing';
|
import { traceErrorOnly } from '@/lib/tracing';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return true if a change-request has been updated.
|
* Return true if the content has been updated.
|
||||||
|
* It compares the revision ID displayed on the client side with the latest revision ID from the server/cache.
|
||||||
*/
|
*/
|
||||||
export async function hasContentBeenUpdated(props: {
|
export async function hasContentBeenUpdated(props: {
|
||||||
spaceId: string;
|
|
||||||
changeRequestId: string;
|
|
||||||
revisionId: string;
|
revisionId: string;
|
||||||
}) {
|
}) {
|
||||||
return traceErrorOnly('AutoRefreshContent.hasContentBeenUpdated', async () => {
|
return traceErrorOnly('AutoRefreshContent.hasContentBeenUpdated', async () => {
|
||||||
const context = await getServerActionBaseContext();
|
const context = await getServerActionBaseContext();
|
||||||
const changeRequest = await getDataOrNull(
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
context.dataFetcher.getChangeRequest({
|
|
||||||
spaceId: props.spaceId,
|
if (siteURLData.changeRequest) {
|
||||||
changeRequestId: props.changeRequestId,
|
const changeRequest = await getDataOrNull(
|
||||||
|
context.dataFetcher.getChangeRequest({
|
||||||
|
spaceId: siteURLData.space,
|
||||||
|
changeRequestId: siteURLData.changeRequest,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (!changeRequest) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return changeRequest.revision !== props.revisionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const space = await getDataOrNull(
|
||||||
|
context.dataFetcher.getSpace({
|
||||||
|
spaceId: siteURLData.space,
|
||||||
|
shareKey: siteURLData.shareKey,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (!changeRequest) {
|
if (!space) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return changeRequest.revision !== props.revisionId;
|
return space.revision !== props.revisionId;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,71 @@ import React from 'react';
|
|||||||
import { hasContentBeenUpdated } from './server-actions';
|
import { hasContentBeenUpdated } from './server-actions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a callback to check if a change request has been updated and to refresh the page if it has.
|
* Return a callback to check if a content has been updated and to refresh the page if it has.
|
||||||
*/
|
*/
|
||||||
export function useCheckForContentUpdate(props: {
|
export function useCheckForContentUpdate(props: {
|
||||||
spaceId: string;
|
/**
|
||||||
changeRequestId: string;
|
* ID of the revision of the content currently being displayed.
|
||||||
|
*/
|
||||||
revisionId: string;
|
revisionId: string;
|
||||||
|
/**
|
||||||
|
* How long after page load to check for updates.
|
||||||
|
* @default 5min
|
||||||
|
*/
|
||||||
|
checkAfterLoad?: number;
|
||||||
}) {
|
}) {
|
||||||
const { spaceId, changeRequestId, revisionId } = props;
|
const { revisionId, checkAfterLoad = 5 * 60 * 1000 } = props;
|
||||||
|
const [updated, setUpdated] = React.useState(false);
|
||||||
return React.useCallback(async () => {
|
|
||||||
const updated = await hasContentBeenUpdated({ spaceId, changeRequestId, revisionId });
|
|
||||||
|
|
||||||
|
const checkForUpdates = React.useCallback(async () => {
|
||||||
if (updated) {
|
if (updated) {
|
||||||
|
// No need to check for updates if we already know the content has been updated
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await hasContentBeenUpdated({ revisionId });
|
||||||
|
setUpdated(result);
|
||||||
|
return result;
|
||||||
|
}, [revisionId, updated]);
|
||||||
|
|
||||||
|
const refreshForUpdates = React.useCallback(async () => {
|
||||||
|
if (await checkForUpdates()) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
}, [spaceId, changeRequestId, revisionId]);
|
}, [checkForUpdates]);
|
||||||
|
|
||||||
|
// Check for updates after the page has loaded
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
// We check only if the tab is visible; otherwise we'll check when the tab is focus again
|
||||||
|
checkForUpdates();
|
||||||
|
}
|
||||||
|
}, checkAfterLoad);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [checkForUpdates, checkAfterLoad]);
|
||||||
|
|
||||||
|
// Check for updates when the tab is becoming visible
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (updated) {
|
||||||
|
// No need to check for updates if we already know the content has been updated
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callback = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
checkForUpdates();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', callback);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', callback);
|
||||||
|
};
|
||||||
|
}, [checkForUpdates, updated]);
|
||||||
|
|
||||||
|
return { refreshForUpdates, updated };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export async function highlight(
|
|||||||
currentIndex += 1; // for the \n
|
currentIndex += 1; // for the \n
|
||||||
|
|
||||||
return {
|
return {
|
||||||
highlighted: Boolean(lineBlock.data.highlighted),
|
highlighted: Boolean(lineBlock?.data.highlighted),
|
||||||
tokens: result,
|
tokens: result,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import type { DocumentBlockColumns, Length } from '@gitbook/api';
|
import { type DocumentBlockColumns, type Length, VerticalAlignment } from '@gitbook/api';
|
||||||
import type { BlockProps } from '../Block';
|
import type { BlockProps } from '../Block';
|
||||||
import { Blocks } from '../Blocks';
|
import { Blocks } from '../Blocks';
|
||||||
|
|
||||||
@@ -8,10 +8,12 @@ export function Columns(props: BlockProps<DocumentBlockColumns>) {
|
|||||||
return (
|
return (
|
||||||
<div className={tcls('flex flex-col gap-x-8 md:flex-row', style)}>
|
<div className={tcls('flex flex-col gap-x-8 md:flex-row', style)}>
|
||||||
{block.nodes.map((columnBlock) => {
|
{block.nodes.map((columnBlock) => {
|
||||||
const width = columnBlock.data.width;
|
|
||||||
const { className, style } = transformLengthToCSS(width);
|
|
||||||
return (
|
return (
|
||||||
<Column key={columnBlock.key} className={className} style={style}>
|
<Column
|
||||||
|
key={columnBlock.key}
|
||||||
|
width={columnBlock.data.width}
|
||||||
|
verticalAlignment={columnBlock.data.verticalAlignment}
|
||||||
|
>
|
||||||
<Blocks
|
<Blocks
|
||||||
key={columnBlock.key}
|
key={columnBlock.key}
|
||||||
nodes={columnBlock.nodes}
|
nodes={columnBlock.nodes}
|
||||||
@@ -29,11 +31,23 @@ export function Columns(props: BlockProps<DocumentBlockColumns>) {
|
|||||||
|
|
||||||
export function Column(props: {
|
export function Column(props: {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: ClassValue;
|
width?: Length;
|
||||||
style?: React.CSSProperties;
|
verticalAlignment?: VerticalAlignment;
|
||||||
}) {
|
}) {
|
||||||
|
const { width, verticalAlignment } = props;
|
||||||
|
const { className, style } = transformLengthToCSS(width);
|
||||||
return (
|
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}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,13 +7,16 @@ import { Block, type BlockProps } from './Block';
|
|||||||
import { Blocks } from './Blocks';
|
import { Blocks } from './Blocks';
|
||||||
import { getBlockTextStyle } from './spacing';
|
import { getBlockTextStyle } from './spacing';
|
||||||
|
|
||||||
export function Hint(props: BlockProps<DocumentBlockHint>) {
|
export function Hint({
|
||||||
const { block, style, ancestorBlocks, ...contextProps } = props;
|
block,
|
||||||
|
style,
|
||||||
|
ancestorBlocks,
|
||||||
|
...contextProps
|
||||||
|
}: BlockProps<DocumentBlockHint>) {
|
||||||
const hintStyle = HINT_STYLES[block.data.style] ?? HINT_STYLES.info;
|
const hintStyle = HINT_STYLES[block.data.style] ?? HINT_STYLES.info;
|
||||||
const firstLine = getBlockTextStyle(block.nodes[0]);
|
const firstNode = block.nodes[0]!;
|
||||||
|
const firstLine = getBlockTextStyle(firstNode);
|
||||||
const firstNode = block.nodes[0];
|
const hasHeading = ['heading-1', 'heading-2', 'heading-3'].includes(firstNode.type);
|
||||||
const hasHeading = ['heading-1', 'heading-2', 'heading-3'].includes(block.nodes[0].type);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -20,10 +20,7 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
|
|||||||
: null;
|
: null;
|
||||||
const { contentContext } = context;
|
const { contentContext } = context;
|
||||||
|
|
||||||
const language =
|
const language = contentContext ? getSpaceLanguage(contentContext) : languages.en;
|
||||||
contentContext && 'customization' in contentContext
|
|
||||||
? getSpaceLanguage(contentContext.customization)
|
|
||||||
: languages.en;
|
|
||||||
|
|
||||||
if (!contentContext || !resolved) {
|
if (!contentContext || !resolved) {
|
||||||
return (
|
return (
|
||||||
@@ -49,6 +46,7 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const isExternal = inline.data.ref.kind === 'url';
|
const isExternal = inline.data.ref.kind === 'url';
|
||||||
|
const isMailto = resolved.href.startsWith('mailto:');
|
||||||
const content = (
|
const content = (
|
||||||
<StyledLink
|
<StyledLink
|
||||||
href={resolved.href}
|
href={resolved.href}
|
||||||
@@ -66,7 +64,12 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
|
|||||||
nodes={inline.nodes}
|
nodes={inline.nodes}
|
||||||
ancestorInlines={[...ancestorInlines, inline]}
|
ancestorInlines={[...ancestorInlines, inline]}
|
||||||
/>
|
/>
|
||||||
{isExternal ? (
|
{isMailto ? (
|
||||||
|
<Icon
|
||||||
|
icon="envelope"
|
||||||
|
className="ml-1 inline size-3 links-accent:text-tint-subtle"
|
||||||
|
/>
|
||||||
|
) : isExternal ? (
|
||||||
<Icon
|
<Icon
|
||||||
icon="arrow-up-right"
|
icon="arrow-up-right"
|
||||||
className="ml-0.5 inline size-3 links-accent:text-tint-subtle"
|
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;
|
const { inline, language, resolved, children } = props;
|
||||||
|
|
||||||
let breadcrumbs = resolved.ancestors ?? [];
|
let breadcrumbs = resolved.ancestors ?? [];
|
||||||
|
const isMailto = resolved.href.startsWith('mailto:');
|
||||||
const isExternal = inline.data.ref.kind === 'url';
|
const isExternal = inline.data.ref.kind === 'url';
|
||||||
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
|
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 = [
|
breadcrumbs = [
|
||||||
{
|
{
|
||||||
label: tString(language, 'link_tooltip_external_link'),
|
label: tString(language, 'link_tooltip_external_link'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
} else if (isSamePage) {
|
||||||
if (isSamePage) {
|
|
||||||
breadcrumbs = [
|
breadcrumbs = [
|
||||||
{
|
{
|
||||||
label: tString(language, 'link_tooltip_page_anchor'),
|
label: tString(language, 'link_tooltip_page_anchor'),
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.contentkit-button-style-primary {
|
.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 {
|
.contentkit-button-style-secondary {
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ export function ListItem(props: BlockProps<DocumentBlockListItem>) {
|
|||||||
|
|
||||||
const parent = ancestorBlocks[ancestorBlocks.length - 1];
|
const parent = ancestorBlocks[ancestorBlocks.length - 1];
|
||||||
assert(
|
assert(
|
||||||
(parent && parent.type === 'list-ordered') ||
|
parent?.type === 'list-ordered' ||
|
||||||
parent.type === 'list-unordered' ||
|
parent?.type === 'list-unordered' ||
|
||||||
parent.type === 'list-tasks',
|
parent?.type === 'list-tasks',
|
||||||
'Invalid parent list type'
|
'Invalid parent list type'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -112,11 +112,11 @@ function getListItemDepth(input: {
|
|||||||
|
|
||||||
for (let i = ancestorBlocks.length - 1; i >= 0; i--) {
|
for (let i = ancestorBlocks.length - 1; i >= 0; i--) {
|
||||||
const block = ancestorBlocks[i];
|
const block = ancestorBlocks[i];
|
||||||
if (block.type === type) {
|
if (block?.type === type) {
|
||||||
depth = depth + 1;
|
depth = depth + 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (block.type === 'list-item') {
|
if (block?.type === 'list-item') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Heading } from '../Heading';
|
|||||||
|
|
||||||
import './scalar.css';
|
import './scalar.css';
|
||||||
import './style.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 { GitBookAnyContext } from '@/lib/context';
|
||||||
import type {
|
import type {
|
||||||
AnyOpenAPIOperationsBlock,
|
AnyOpenAPIOperationsBlock,
|
||||||
@@ -30,10 +30,7 @@ export function getOpenAPIContext(args: {
|
|||||||
const { props, specUrl, context } = args;
|
const { props, specUrl, context } = args;
|
||||||
const { block } = props;
|
const { block } = props;
|
||||||
|
|
||||||
const customization = context && 'customization' in context ? context.customization : null;
|
const customizationLocale = context ? getSpaceLocale(context) : DEFAULT_LOCALE;
|
||||||
const customizationLocale = customization
|
|
||||||
? getCustomizationLocale(customization)
|
|
||||||
: DEFAULT_LOCALE;
|
|
||||||
const locale = checkIsValidLocale(customizationLocale) ? customizationLocale : DEFAULT_LOCALE;
|
const locale = checkIsValidLocale(customizationLocale) ? customizationLocale : DEFAULT_LOCALE;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -272,9 +272,8 @@ body {
|
|||||||
}
|
}
|
||||||
.scalar-activate-button {
|
.scalar-activate-button {
|
||||||
@apply flex gap-2 items-center;
|
@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 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 ring-1 ring-tint hover:ring-tint-hover;
|
@apply shadow-sm shadow-primary dark:shadow-primary-1 hover:shadow-md active:shadow-none;
|
||||||
@apply shadow-sm shadow-tint dark:shadow-tint-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 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 hover:scale-105 active:scale-100 transition-all;
|
||||||
@apply grow-0 shrink-0 truncate;
|
@apply grow-0 shrink-0 truncate;
|
||||||
|
|||||||
@@ -565,7 +565,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-example-empty {
|
.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 */
|
/* Common Elements */
|
||||||
@@ -746,6 +746,10 @@ body:has(.openapi-select-popover) {
|
|||||||
@apply px-3 transition-all;
|
@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 {
|
.openapi-disclosure-group-trigger[aria-expanded="true"] > .openapi-disclosure-group-icon > svg {
|
||||||
@apply rotate-90;
|
@apply rotate-90;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
|
|||||||
const { block, style, ancestorBlocks, ...contextProps } = props;
|
const { block, style, ancestorBlocks, ...contextProps } = props;
|
||||||
|
|
||||||
const ancestor = ancestorBlocks[ancestorBlocks.length - 1];
|
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);
|
const index = ancestor.nodes.indexOf(block);
|
||||||
|
|
||||||
@@ -36,8 +36,8 @@ export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
|
|||||||
<div className="relative select-none">
|
<div className="relative select-none">
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
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',
|
'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-solid theme-muted:text-primary'
|
'font-medium text-contrast-primary-original theme-muted:text-primary contrast-more:text-contrast-primary-solid'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{index + 1}
|
{index + 1}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Image } from '@/components/utils';
|
|||||||
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
|
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import {
|
import {
|
||||||
|
CardsImageObjectFit,
|
||||||
type ContentRef,
|
type ContentRef,
|
||||||
type DocumentTableViewCards,
|
type DocumentTableViewCards,
|
||||||
SiteInsightsLinkPosition,
|
SiteInsightsLinkPosition,
|
||||||
@@ -25,8 +26,12 @@ export async function RecordCard(
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const [lightCover, darkCover, target] = await Promise.all([
|
const [lightCover, darkCover, target] = await Promise.all([
|
||||||
light && context.contentContext ? resolveContentRef(light, context.contentContext) : null,
|
light.contentRef && context.contentContext
|
||||||
dark && context.contentContext ? resolveContentRef(dark, context.contentContext) : null,
|
? resolveContentRef(light.contentRef, context.contentContext)
|
||||||
|
: null,
|
||||||
|
dark.contentRef && context.contentContext
|
||||||
|
? resolveContentRef(dark.contentRef, context.contentContext)
|
||||||
|
: null,
|
||||||
targetRef && context.contentContext
|
targetRef && context.contentContext
|
||||||
? resolveContentRef(targetRef, context.contentContext)
|
? resolveContentRef(targetRef, context.contentContext)
|
||||||
: null,
|
: null,
|
||||||
@@ -35,6 +40,10 @@ export async function RecordCard(
|
|||||||
const darkCoverIsSquareOrPortrait = isSquareOrPortrait(darkCover);
|
const darkCoverIsSquareOrPortrait = isSquareOrPortrait(darkCover);
|
||||||
const lightCoverIsSquareOrPortrait = isSquareOrPortrait(lightCover);
|
const lightCoverIsSquareOrPortrait = isSquareOrPortrait(lightCover);
|
||||||
|
|
||||||
|
const darkObjectFit = `dark:${getObjectFitClass(dark.objectFit)}`;
|
||||||
|
const lightObjectFit = getObjectFitClass(light.objectFit);
|
||||||
|
const objectFits = `${lightObjectFit} ${darkObjectFit}`;
|
||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
@@ -94,7 +103,7 @@ export async function RecordCard(
|
|||||||
'min-w-0',
|
'min-w-0',
|
||||||
'w-full',
|
'w-full',
|
||||||
'h-full',
|
'h-full',
|
||||||
'object-cover',
|
'bg-tint-subtle',
|
||||||
lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
|
lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
|
||||||
? [
|
? [
|
||||||
lightCoverIsSquareOrPortrait
|
lightCoverIsSquareOrPortrait
|
||||||
@@ -104,7 +113,8 @@ export async function RecordCard(
|
|||||||
? 'dark:min-[432px]:aspect-video dark:min-[432px]:h-auto'
|
? 'dark:min-[432px]:aspect-video dark:min-[432px]:h-auto'
|
||||||
: '',
|
: '',
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
: ['h-auto', 'aspect-video']
|
: ['h-auto', 'aspect-video'],
|
||||||
|
objectFits
|
||||||
)}
|
)}
|
||||||
priority={isOffscreen ? 'lazy' : 'high'}
|
priority={isOffscreen ? 'lazy' : 'high'}
|
||||||
preload
|
preload
|
||||||
@@ -190,3 +200,23 @@ function isSquareOrPortrait(contentRef: ResolvedContentRef | null) {
|
|||||||
|
|
||||||
return file.dimensions?.width / file.dimensions?.height <= 1;
|
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 { resolveContentRef } from '@/lib/references';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { filterOutNullable } from '@/lib/typescript';
|
import { filterOutNullable } from '@/lib/typescript';
|
||||||
|
|
||||||
import type { BlockProps } from '../Block';
|
import type { BlockProps } from '../Block';
|
||||||
import { Blocks } from '../Blocks';
|
import { Blocks } from '../Blocks';
|
||||||
import { FileIcon } from '../FileIcon';
|
import { FileIcon } from '../FileIcon';
|
||||||
import type { TableRecordKV } from './Table';
|
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> = {
|
const alignmentMap: Record<'text-left' | 'text-center' | 'text-right', string> = {
|
||||||
'text-left': '**:text-left text-left',
|
'text-left': '**:text-left text-left',
|
||||||
@@ -58,18 +63,28 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
|||||||
return null;
|
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) {
|
switch (definition.type) {
|
||||||
case 'checkbox':
|
case 'checkbox': {
|
||||||
|
if (value === null || typeof value !== 'boolean') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
className={tcls('w-5', 'h-5')}
|
className={tcls('w-5', 'h-5')}
|
||||||
checked={value as boolean}
|
checked={value}
|
||||||
disabled={true}
|
disabled={true}
|
||||||
aria-labelledby={ariaLabelledBy}
|
aria-labelledby={ariaLabelledBy}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case 'rating': {
|
case 'rating': {
|
||||||
const rating = value as number;
|
if (typeof value !== 'number') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rating = value;
|
||||||
const max = definition.max;
|
const max = definition.max;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -108,15 +123,21 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
|||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'number':
|
case 'number': {
|
||||||
|
if (typeof value !== 'number') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Tag
|
<Tag
|
||||||
className={tcls('text-base', 'tabular-nums', 'tracking-tighter')}
|
className={tcls('text-base', 'tabular-nums', 'tracking-tighter')}
|
||||||
aria-labelledby={ariaLabelledBy}
|
aria-labelledby={ariaLabelledBy}
|
||||||
>{`${value}`}</Tag>
|
>{`${value}`}</Tag>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case 'text': {
|
case 'text': {
|
||||||
// @ts-ignore
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const fragment = getNodeFragmentByName(block, value);
|
const fragment = getNodeFragmentByName(block, value);
|
||||||
if (!fragment) {
|
if (!fragment) {
|
||||||
return <Tag className={tcls(['w-full', verticalAlignment])}>{''}</Tag>;
|
return <Tag className={tcls(['w-full', verticalAlignment])}>{''}</Tag>;
|
||||||
@@ -149,8 +170,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'files': {
|
case 'files': {
|
||||||
|
if (!isStringArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const files = await Promise.all(
|
const files = await Promise.all(
|
||||||
(value as string[]).map((fileId) =>
|
value.map((fileId) =>
|
||||||
context.contentContext
|
context.contentContext
|
||||||
? resolveContentRef(
|
? resolveContentRef(
|
||||||
{
|
{
|
||||||
@@ -221,10 +245,12 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'content-ref': {
|
case 'content-ref': {
|
||||||
const contentRef = value ? (value as ContentRef) : null;
|
if (value === null || !isContentRef(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const resolved =
|
const resolved =
|
||||||
contentRef && context.contentContext
|
value && context.contentContext
|
||||||
? await resolveContentRef(contentRef, context.contentContext, {
|
? await resolveContentRef(value, context.contentContext, {
|
||||||
resolveAnchorText: true,
|
resolveAnchorText: true,
|
||||||
iconStyle: ['mr-2', 'text-tint-subtle'],
|
iconStyle: ['mr-2', 'text-tint-subtle'],
|
||||||
})
|
})
|
||||||
@@ -239,11 +265,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
|||||||
<StyledLink
|
<StyledLink
|
||||||
href={resolved.href}
|
href={resolved.href}
|
||||||
insights={
|
insights={
|
||||||
contentRef
|
value
|
||||||
? {
|
? {
|
||||||
type: 'link_click',
|
type: 'link_click',
|
||||||
link: {
|
link: {
|
||||||
target: contentRef,
|
target: value,
|
||||||
position: SiteInsightsLinkPosition.Content,
|
position: SiteInsightsLinkPosition.Content,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -257,8 +283,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'users': {
|
case 'users': {
|
||||||
|
if (!isStringArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const resolved = await Promise.all(
|
const resolved = await Promise.all(
|
||||||
(value as string[]).map(async (userId) => {
|
value.map(async (userId) => {
|
||||||
const contentRef: ContentRefUser = {
|
const contentRef: ContentRefUser = {
|
||||||
kind: 'user',
|
kind: 'user',
|
||||||
user: userId,
|
user: userId,
|
||||||
@@ -295,10 +324,13 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'select': {
|
case 'select': {
|
||||||
|
if (!isStringArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Tag aria-labelledby={ariaLabelledBy}>
|
<Tag aria-labelledby={ariaLabelledBy}>
|
||||||
<span className={tcls('inline-flex', 'gap-2', 'flex-wrap')}>
|
<span className={tcls('inline-flex', 'gap-2', 'flex-wrap')}>
|
||||||
{(value as string[]).map((selectId) => {
|
{value.map((selectId) => {
|
||||||
const option = definition.options.find(
|
const option = definition.options.find(
|
||||||
(option) => option.value === selectId
|
(option) => option.value === selectId
|
||||||
);
|
);
|
||||||
@@ -329,8 +361,15 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'image': {
|
case 'image': {
|
||||||
|
if (!isDocumentTableImageRecord(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const image = context.contentContext
|
const image = context.contentContext
|
||||||
? await resolveContentRef(value as ContentRef, context.contentContext)
|
? await resolveContentRef(
|
||||||
|
'ref' in value ? value.ref : value,
|
||||||
|
context.contentContext
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!image) {
|
if (!image) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function RecordRow(
|
|||||||
autoSizedColumns,
|
autoSizedColumns,
|
||||||
fixedColumns,
|
fixedColumns,
|
||||||
});
|
});
|
||||||
|
// @ts-expect-error
|
||||||
const verticalAlignment = getColumnVerticalAlignment(block.data.definition[column]);
|
const verticalAlignment = getColumnVerticalAlignment(block.data.definition[column]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) {
|
|||||||
/* Only show the header when configured and not empty */
|
/* Only show the header when configured and not empty */
|
||||||
const withHeader =
|
const withHeader =
|
||||||
!view.hideHeader &&
|
!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 (
|
return (
|
||||||
<div className={tcls(style, styles.tableWrapper)}>
|
<div className={tcls(style, styles.tableWrapper)}>
|
||||||
@@ -43,28 +45,31 @@ export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div role="row" className={tcls('flex', 'w-full')}>
|
<div role="row" className={tcls('flex', 'w-full')}>
|
||||||
{view.columns.map((column) => (
|
{view.columns.map((column) => {
|
||||||
<div
|
const definition = block.data.definition[column]!;
|
||||||
key={column}
|
return (
|
||||||
role="columnheader"
|
<div
|
||||||
className={tcls(
|
key={column}
|
||||||
styles.columnHeader,
|
role="columnheader"
|
||||||
getColumnAlignment(block.data.definition[column])
|
className={tcls(
|
||||||
)}
|
styles.columnHeader,
|
||||||
style={{
|
getColumnAlignment(definition)
|
||||||
width: getColumnWidth({
|
)}
|
||||||
column,
|
style={{
|
||||||
columnWidths,
|
width: getColumnWidth({
|
||||||
autoSizedColumns,
|
column,
|
||||||
fixedColumns,
|
columnWidths,
|
||||||
}),
|
autoSizedColumns,
|
||||||
minWidth: columnWidths?.[column] || '100px',
|
fixedColumns,
|
||||||
}}
|
}),
|
||||||
title={block.data.definition[column].title}
|
minWidth: columnWidths?.[column] || '100px',
|
||||||
>
|
}}
|
||||||
{block.data.definition[column].title}
|
title={definition.title}
|
||||||
</div>
|
>
|
||||||
))}
|
{definition.title}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
|
CardsImageObjectFit,
|
||||||
ContentRef,
|
ContentRef,
|
||||||
ContentRefFile,
|
ContentRefFile,
|
||||||
ContentRefURL,
|
ContentRefURL,
|
||||||
DocumentTableDefinition,
|
DocumentTableDefinition,
|
||||||
|
DocumentTableImageRecord,
|
||||||
DocumentTableRecord,
|
DocumentTableRecord,
|
||||||
DocumentTableViewCards,
|
DocumentTableViewCards,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
@@ -21,53 +23,64 @@ export function getRecordValue<T extends number | string | boolean | string[] |
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the covers for a record card.
|
* 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 light cover is a string or a content ref (image or files column type).
|
||||||
* The dark cover is a content ref (image column type).
|
* The dark cover is a content ref (image column type).
|
||||||
*/
|
*/
|
||||||
export function getRecordCardCovers(
|
export function getRecordCardCovers(
|
||||||
record: DocumentTableRecord,
|
record: DocumentTableRecord,
|
||||||
view: DocumentTableViewCards
|
view: DocumentTableViewCards
|
||||||
): { [key in 'light' | 'dark']: ContentRefFile | ContentRefURL | null } {
|
): {
|
||||||
return {
|
[key in 'light' | 'dark']: {
|
||||||
light: (() => {
|
contentRef: ContentRefFile | ContentRefURL | null;
|
||||||
if (!view.coverDefinition) {
|
objectFit?: CardsImageObjectFit;
|
||||||
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;
|
|
||||||
})(),
|
|
||||||
};
|
};
|
||||||
|
} {
|
||||||
|
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';
|
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',
|
'max-w-full',
|
||||||
'truncate',
|
'truncate',
|
||||||
|
|
||||||
active.id === tab.id
|
active?.id === tab.id
|
||||||
? [
|
? [
|
||||||
'shrink-0',
|
'shrink-0',
|
||||||
'active-tab',
|
'active-tab',
|
||||||
@@ -222,7 +222,7 @@ export function DynamicTabs(
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={active.id === tab.id}
|
aria-selected={active?.id === tab.id}
|
||||||
aria-controls={getTabPanelId(tab.id)}
|
aria-controls={getTabPanelId(tab.id)}
|
||||||
id={getTabButtonId(tab.id)}
|
id={getTabButtonId(tab.id)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -255,7 +255,7 @@ export function DynamicTabs(
|
|||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
id={getTabPanelId(tab.id)}
|
id={getTabPanelId(tab.id)}
|
||||||
aria-labelledby={getTabButtonId(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]}
|
{tabsBody[index]}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,15 +59,15 @@ interface MarkedLeafProps<Mark extends DocumentTextMark> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Bold(props: MarkedLeafProps<DocumentMarkBold>) {
|
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>) {
|
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>) {
|
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>) {
|
function Keyboard(props: MarkedLeafProps<DocumentMarkKeyboard>) {
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ export function getBlockTextStyle(block: DocumentBlock): {
|
|||||||
case 'list-ordered':
|
case 'list-ordered':
|
||||||
case 'list-tasks':
|
case 'list-tasks':
|
||||||
case 'list-unordered':
|
case 'list-unordered':
|
||||||
return getBlockTextStyle(block.nodes[0]);
|
return getBlockTextStyle(block.nodes[0]!);
|
||||||
case 'list-item':
|
case 'list-item':
|
||||||
return getBlockTextStyle(block.nodes[0]);
|
return getBlockTextStyle(block.nodes[0]!);
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
textSize: 'text-base',
|
textSize: 'text-base',
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function isBlockOffscreen(
|
|||||||
|
|
||||||
const allAncestors = [document, ...ancestorBlocks];
|
const allAncestors = [document, ...ancestorBlocks];
|
||||||
for (let index = allAncestors.length - 1; index >= 0; index--) {
|
for (let index = allAncestors.length - 1; index >= 0; index--) {
|
||||||
const parent = allAncestors[index];
|
const parent = allAncestors[index]!;
|
||||||
const offset = getBlockOffset({ block: current, parent });
|
const offset = getBlockOffset({ block: current, parent });
|
||||||
|
|
||||||
if (offset > screenHeight) {
|
if (offset > screenHeight) {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
AIChatDynamicIcon,
|
AIChatDynamicIcon,
|
||||||
AIChatSubtitle,
|
AIChatSubtitle,
|
||||||
} from '@/components/AIChat';
|
} from '@/components/AIChat';
|
||||||
|
import * as api from '@gitbook/api';
|
||||||
|
import React from 'react';
|
||||||
|
import { useTrackEvent } from '../Insights';
|
||||||
import {
|
import {
|
||||||
EmbeddableFrame,
|
EmbeddableFrame,
|
||||||
EmbeddableFrameBody,
|
EmbeddableFrameBody,
|
||||||
@@ -28,6 +31,20 @@ export function EmbeddableAIChat(props: {
|
|||||||
const chatController = useAIChatController();
|
const chatController = useAIChatController();
|
||||||
const configuration = useEmbeddableConfiguration();
|
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 (
|
return (
|
||||||
<EmbeddableFrame>
|
<EmbeddableFrame>
|
||||||
<EmbeddableFrameHeader>
|
<EmbeddableFrameHeader>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type PagePathParams, getSitePageData } from '@/components/SitePage';
|
|||||||
|
|
||||||
import { PageBody } from '@/components/PageBody';
|
import { PageBody } from '@/components/PageBody';
|
||||||
import type { GitBookSiteContext } from '@/lib/context';
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
|
import { SiteInsightsDisplayContext } from '@gitbook/api';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Button } from '../primitives';
|
import { Button } from '../primitives';
|
||||||
import {
|
import {
|
||||||
@@ -54,6 +55,7 @@ export async function EmbeddableDocsPage(props: EmbeddableDocsPageProps) {
|
|||||||
ancestors={ancestors}
|
ancestors={ancestors}
|
||||||
document={document}
|
document={document}
|
||||||
withPageFeedback={withPageFeedback}
|
withPageFeedback={withPageFeedback}
|
||||||
|
insightsDisplayContext={SiteInsightsDisplayContext.Embed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</EmbeddableFrameBody>
|
</EmbeddableFrameBody>
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { AIChatProvider, AIContextProvider } from '@/components/AI';
|
import { AIContextProvider } from '@/components/AI';
|
||||||
import { CustomizationRootLayout } from '@/components/RootLayout';
|
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||||
import {
|
import {
|
||||||
SiteLayoutClientContexts,
|
SiteLayoutClientContexts,
|
||||||
generateSiteLayoutMetadata,
|
generateSiteLayoutMetadata,
|
||||||
generateSiteLayoutViewport,
|
generateSiteLayoutViewport,
|
||||||
} from '@/components/SiteLayout';
|
} from '@/components/SiteLayout';
|
||||||
|
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||||
import type { GitBookSiteContext } from '@/lib/context';
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
import { CustomizationAIMode } from '@gitbook/api';
|
import { CustomizationAIMode } from '@gitbook/api';
|
||||||
|
import { SpaceLayoutServerContext } from '../SpaceLayout';
|
||||||
import { EmbeddableIframeAPI } from './EmbeddableIframeAPI';
|
import { EmbeddableIframeAPI } from './EmbeddableIframeAPI';
|
||||||
|
|
||||||
type EmbeddableRootLayoutProps = {
|
type EmbeddableRootLayoutProps = {
|
||||||
context: GitBookSiteContext;
|
context: GitBookSiteContext;
|
||||||
|
withTracking: boolean;
|
||||||
|
visitorAuthClaims: VisitorAuthClaims;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,10 +22,12 @@ type EmbeddableRootLayoutProps = {
|
|||||||
*/
|
*/
|
||||||
export async function EmbeddableRootLayout({
|
export async function EmbeddableRootLayout({
|
||||||
context,
|
context,
|
||||||
|
withTracking,
|
||||||
|
visitorAuthClaims,
|
||||||
children,
|
children,
|
||||||
}: React.PropsWithChildren<EmbeddableRootLayoutProps>) {
|
}: React.PropsWithChildren<EmbeddableRootLayoutProps>) {
|
||||||
return (
|
return (
|
||||||
<CustomizationRootLayout customization={context.customization}>
|
<CustomizationRootLayout context={context}>
|
||||||
<SiteLayoutClientContexts
|
<SiteLayoutClientContexts
|
||||||
forcedTheme={context.customization.themes.default}
|
forcedTheme={context.customization.themes.default}
|
||||||
externalLinksTarget={context.customization.externalLinks.target}
|
externalLinksTarget={context.customization.externalLinks.target}
|
||||||
@@ -31,8 +37,11 @@ export async function EmbeddableRootLayout({
|
|||||||
aiMode={CustomizationAIMode.Assistant}
|
aiMode={CustomizationAIMode.Assistant}
|
||||||
trademark={context.customization.trademark.enabled}
|
trademark={context.customization.trademark.enabled}
|
||||||
>
|
>
|
||||||
<AIChatProvider
|
<SpaceLayoutServerContext
|
||||||
renderMessageOptions={{
|
context={context}
|
||||||
|
withTracking={withTracking}
|
||||||
|
visitorAuthClaims={visitorAuthClaims}
|
||||||
|
aiChatRenderMessageOptions={{
|
||||||
withLinkPreviews: false,
|
withLinkPreviews: false,
|
||||||
asEmbeddable: true,
|
asEmbeddable: true,
|
||||||
}}
|
}}
|
||||||
@@ -41,17 +50,17 @@ export async function EmbeddableRootLayout({
|
|||||||
<EmbeddableIframeAPI
|
<EmbeddableIframeAPI
|
||||||
baseURL={context.linker.toPathInSpace('~gitbook/embed/')}
|
baseURL={context.linker.toPathInSpace('~gitbook/embed/')}
|
||||||
/>
|
/>
|
||||||
</AIChatProvider>
|
</SpaceLayoutServerContext>
|
||||||
</AIContextProvider>
|
</AIContextProvider>
|
||||||
</SiteLayoutClientContexts>
|
</SiteLayoutClientContexts>
|
||||||
</CustomizationRootLayout>
|
</CustomizationRootLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateEmbeddableViewport({ context }: EmbeddableRootLayoutProps) {
|
export async function generateEmbeddableViewport({ context }: { context: GitBookSiteContext }) {
|
||||||
return generateSiteLayoutViewport(context);
|
return generateSiteLayoutViewport(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateEmbeddableMetadata({ context }: EmbeddableRootLayoutProps) {
|
export async function generateEmbeddableMetadata({ context }: { context: GitBookSiteContext }) {
|
||||||
return generateSiteLayoutMetadata(context);
|
return generateSiteLayoutMetadata(context);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,111 +32,109 @@ export function Footer(props: { context: GitBookSiteContext }) {
|
|||||||
mobileOnly ? 'xl:hidden' : null
|
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(CONTAINER_STYLE, 'px-4', 'py-8', 'lg:py-12', 'mx-auto')}>
|
<div
|
||||||
<div
|
className={tcls(
|
||||||
className={tcls(
|
'mx-auto grid max-w-3xl site-width-wide:max-w-screen-2xl justify-between gap-12 lg:max-w-none!',
|
||||||
'mx-auto grid max-w-3xl site-width-wide:max-w-screen-2xl justify-between gap-12 lg:max-w-none!',
|
'grid-cols-[auto_auto]',
|
||||||
'grid-cols-[auto_auto]',
|
'lg:grid-cols-[18rem_minmax(auto,48rem)_auto]',
|
||||||
'lg:grid-cols-[18rem_minmax(auto,48rem)_auto]',
|
'xl:grid-cols-[18rem_minmax(auto,48rem)_14rem]',
|
||||||
'xl:grid-cols-[18rem_minmax(auto,48rem)_14rem]',
|
'lg:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_auto]',
|
||||||
'lg:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_auto]',
|
'xl:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_14rem]',
|
||||||
'xl:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_14rem]',
|
'lg:page-no-toc:grid-cols-[minmax(auto,48rem)_auto]',
|
||||||
'lg:page-no-toc:grid-cols-[minmax(auto,48rem)_auto]',
|
'xl:page-no-toc:grid-cols-[14rem_minmax(auto,48rem)_14rem]',
|
||||||
'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]',
|
||||||
'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]'
|
||||||
'xl:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[14rem_minmax(auto,90rem)_14rem]'
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{
|
||||||
{
|
// Footer Logo
|
||||||
// Footer Logo
|
customization.footer.logo ? (
|
||||||
customization.footer.logo ? (
|
<div className="col-start-1 row-start-1">
|
||||||
<div className="col-start-1 row-start-1">
|
<Image
|
||||||
<Image
|
alt="Logo"
|
||||||
alt="Logo"
|
resize={context.imageResizer}
|
||||||
resize={context.imageResizer}
|
sources={{
|
||||||
sources={{
|
light: {
|
||||||
light: {
|
src: customization.footer.logo.light,
|
||||||
src: customization.footer.logo.light,
|
},
|
||||||
},
|
dark: customization.footer.logo.dark
|
||||||
dark: customization.footer.logo.dark
|
? {
|
||||||
? {
|
src: customization.footer.logo.dark,
|
||||||
src: customization.footer.logo.dark,
|
}
|
||||||
}
|
: null,
|
||||||
: null,
|
}}
|
||||||
}}
|
priority="lazy"
|
||||||
priority="lazy"
|
style={[
|
||||||
style={[
|
'w-auto',
|
||||||
'w-auto',
|
'max-w-40',
|
||||||
'max-w-40',
|
'lg:max-w-64',
|
||||||
'lg:max-w-64',
|
'max-h-10',
|
||||||
'max-h-10',
|
'lg:max-h-12',
|
||||||
'lg:max-h-12',
|
'object-contain',
|
||||||
'object-contain',
|
'object-left',
|
||||||
'object-left',
|
'rounded-sm',
|
||||||
'rounded-sm',
|
'straight-corners:rounded-xs',
|
||||||
'straight-corners:rounded-xs',
|
]}
|
||||||
]}
|
sizes={[
|
||||||
sizes={[
|
{
|
||||||
{
|
width: 320,
|
||||||
width: 320,
|
},
|
||||||
},
|
]}
|
||||||
]}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
) : null
|
||||||
) : null
|
}
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{
|
||||||
// Theme Toggle
|
// Theme Toggle
|
||||||
customization.themes.toggeable ? (
|
customization.themes.toggeable ? (
|
||||||
<div className="-col-start-2 row-start-1 flex items-start justify-end xl:hidden">
|
<div className="-col-start-2 row-start-1 flex items-start justify-end xl:hidden">
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<ThemeToggler />
|
<ThemeToggler />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
// Navigation groups (split into equal columns)
|
// Navigation groups (split into equal columns)
|
||||||
customization.footer.groups?.length > 0 ? (
|
customization.footer.groups?.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
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'
|
'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>
|
</div>
|
||||||
) : null
|
</div>
|
||||||
}
|
) : null
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
// Legal
|
// Legal
|
||||||
customization.footer.copyright ? (
|
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">
|
<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>
|
<p>{customization.footer.copyright}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import type { GitBookSiteContext } from '@/lib/context';
|
|||||||
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
|
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
|
||||||
import { getSpaceLanguage, t } from '@/intl/server';
|
import { getSpaceLanguage, t } from '@/intl/server';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
import { SearchContainer } from '../Search';
|
||||||
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
|
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
|
||||||
import { HeaderLink } from './HeaderLink';
|
import { HeaderLink } from './HeaderLink';
|
||||||
import { HeaderLinkMore } from './HeaderLinkMore';
|
import { HeaderLinkMore } from './HeaderLinkMore';
|
||||||
import { HeaderLinks } from './HeaderLinks';
|
import { HeaderLinks } from './HeaderLinks';
|
||||||
import { HeaderLogo } from './HeaderLogo';
|
import { HeaderLogo } from './HeaderLogo';
|
||||||
import { HeaderMobileMenu } from './HeaderMobileMenu';
|
import { HeaderMobileMenu } from './HeaderMobileMenu';
|
||||||
import { SpacesDropdown } from './SpacesDropdown';
|
import { TranslationsDropdown } from './SpacesDropdown';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the header for the space.
|
* Render the header for the space.
|
||||||
@@ -17,9 +18,9 @@ import { SpacesDropdown } from './SpacesDropdown';
|
|||||||
export function Header(props: {
|
export function Header(props: {
|
||||||
context: GitBookSiteContext;
|
context: GitBookSiteContext;
|
||||||
withTopHeader?: boolean;
|
withTopHeader?: boolean;
|
||||||
search?: React.ReactNode;
|
withVariants?: 'generic' | 'translations';
|
||||||
}) {
|
}) {
|
||||||
const { context, withTopHeader, search } = props;
|
const { context, withTopHeader, withVariants } = props;
|
||||||
const { siteSpace, siteSpaces, sections, customization } = context;
|
const { siteSpace, siteSpaces, sections, customization } = context;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,135 +57,165 @@ export function Header(props: {
|
|||||||
'theme-bold:shadow-tint-12/2'
|
'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
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'gap-4',
|
'flex max-w-full lg:basis-72',
|
||||||
'lg:gap-6',
|
'min-w-0 shrink items-center justify-start gap-2 lg:gap-4'
|
||||||
'flex',
|
|
||||||
'items-center',
|
|
||||||
'justify-between',
|
|
||||||
'w-full',
|
|
||||||
'py-3',
|
|
||||||
'min-h-16',
|
|
||||||
'sm:h-16',
|
|
||||||
CONTAINER_STYLE
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<HeaderMobileMenu
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'flex max-w-full lg:basis-72',
|
'-ml-2',
|
||||||
'min-w-0 shrink items-center justify-start gap-2 lg:gap-4'
|
'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
|
<HeaderLogo context={context} />
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</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}
|
||||||
|
withVariants={withVariants === 'generic'}
|
||||||
|
withSiteVariants={
|
||||||
|
sections?.list.some(
|
||||||
|
(s) =>
|
||||||
|
s.object === 'site-section' &&
|
||||||
|
s.siteSpaces.filter(
|
||||||
|
(s) => s.space.language === siteSpace.space.language
|
||||||
|
).length > 1
|
||||||
|
) ?? false
|
||||||
|
}
|
||||||
|
withSections={!!sections}
|
||||||
|
section={
|
||||||
|
sections
|
||||||
|
? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear
|
||||||
|
encodeClientSiteSections(context, sections).current
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
spaceTitle={siteSpace.title}
|
||||||
|
siteSpaceId={siteSpace.id}
|
||||||
|
siteSpaceIds={siteSpaces
|
||||||
|
.filter((s) => s.space.language === siteSpace.space.language)
|
||||||
|
.map((s) => s.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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sections || siteSpaces.length > 1 ? (
|
{sections ? (
|
||||||
<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(
|
||||||
|
'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
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'w-full',
|
CONTAINER_STYLE,
|
||||||
'overflow-x-auto',
|
'grow',
|
||||||
'no-scrollbar',
|
'flex',
|
||||||
'-mb-4 pb-4', // Positive padding / negative margin allows the navigation menu indicator to show in a scroll viewƒ
|
'items-end',
|
||||||
!sections ? ['hidden', 'page-no-toc:flex'] : 'flex'
|
'page-default-width:2xl:px-[calc((100%-1536px+4rem)/2)]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
{sections.list.some((s) => s.object === 'site-section-group') || // If there's even a single group, show the tabs
|
||||||
className={tcls(
|
sections.list.length > 1 ? ( // Otherwise, show the tabs if there's more than one section
|
||||||
CONTAINER_STYLE,
|
<SiteSectionTabs
|
||||||
'grow',
|
sections={encodeClientSiteSections(context, sections)}
|
||||||
'flex',
|
/>
|
||||||
'items-end',
|
) : null}
|
||||||
'page-default-width:2xl:px-[calc((100%-1536px+4rem)/2)]'
|
{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
|
||||||
{siteSpaces.length > 1 && (
|
context={context}
|
||||||
<div
|
siteSpace={siteSpace}
|
||||||
id="variants"
|
siteSpaces={siteSpaces}
|
||||||
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"
|
/>
|
||||||
>
|
</div>
|
||||||
<SpacesDropdown
|
) : null}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ function HeaderItemButton(
|
|||||||
|
|
||||||
function getHeaderLinkClassName(_props: { headerPreset: CustomizationHeaderPreset }) {
|
function getHeaderLinkClassName(_props: { headerPreset: CustomizationHeaderPreset }) {
|
||||||
return tcls(
|
return tcls(
|
||||||
'flex items-center',
|
'flex items-center gap-1',
|
||||||
'shrink',
|
'shrink',
|
||||||
'contrast-more:underline',
|
'contrast-more:underline',
|
||||||
'truncate',
|
'truncate',
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
import type { SiteSpace } from '@gitbook/api';
|
import type { SiteSpace } from '@gitbook/api';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import type { GitBookSiteContext } from '@/lib/context';
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
import { getSiteSpaceURL } from '@/lib/sites';
|
import { getSiteSpaceURL } from '@/lib/sites';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
import { Button, type ButtonProps } from '../primitives';
|
||||||
import { DropdownChevron, DropdownMenu } from '../primitives/DropdownMenu';
|
import { DropdownChevron, DropdownMenu } from '../primitives/DropdownMenu';
|
||||||
import { SpacesDropdownMenuItems } from './SpacesDropdownMenuItem';
|
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: {
|
export function SpacesDropdown(props: {
|
||||||
context: GitBookSiteContext;
|
context: GitBookSiteContext;
|
||||||
siteSpace: SiteSpace;
|
siteSpace: SiteSpace;
|
||||||
siteSpaces: SiteSpace[];
|
siteSpaces: SiteSpace[];
|
||||||
className?: string;
|
className?: string;
|
||||||
|
variant?: ButtonProps['variant'];
|
||||||
|
icon?: ButtonProps['icon'];
|
||||||
}) {
|
}) {
|
||||||
const { context, siteSpace, siteSpaces, className } = props;
|
const { context, siteSpace, siteSpaces, className, variant = 'secondary', icon } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<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
|
'group-focus-within/dropdown:group-hover/dropdown:visible' // When the dropdown is already open, it should remain visible when hovered
|
||||||
)}
|
)}
|
||||||
button={
|
button={
|
||||||
<div
|
<Button
|
||||||
|
icon={icon}
|
||||||
data-testid="space-dropdown-button"
|
data-testid="space-dropdown-button"
|
||||||
className={tcls(
|
size="medium"
|
||||||
'flex',
|
variant={variant}
|
||||||
'flex-row',
|
trailing={<DropdownChevron />}
|
||||||
'items-center',
|
className={tcls('bg-tint-base', className)}
|
||||||
'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
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span className={tcls('truncate', 'grow')}>{siteSpace.title}</span>
|
<span className="button-content">{siteSpace.title}</span>
|
||||||
<DropdownChevron />
|
</Button>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SpacesDropdownMenuItems
|
<SpacesDropdownMenuItems
|
||||||
@@ -77,3 +56,32 @@ export function SpacesDropdown(props: {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TranslationsDropdown(props: {
|
||||||
|
context: GitBookSiteContext;
|
||||||
|
siteSpace: SiteSpace;
|
||||||
|
siteSpaces: SiteSpace[];
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { context, siteSpace, siteSpaces, className } = props;
|
||||||
|
|
||||||
|
// Memoize the emoji check to avoid repeated regex execution
|
||||||
|
const hasEmojiPrefix = useMemo(() => startsWithEmoji(siteSpace.title), [siteSpace.title]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpacesDropdown
|
||||||
|
icon="globe"
|
||||||
|
context={context}
|
||||||
|
siteSpace={siteSpace}
|
||||||
|
siteSpaces={siteSpaces}
|
||||||
|
variant="blank"
|
||||||
|
className={tcls(
|
||||||
|
'-mx-2 bg-transparent px-2 py-1 lg:max-w-64 max-md:[&_.button-content]:hidden',
|
||||||
|
hasEmojiPrefix
|
||||||
|
? 'md:[&_.button-leading-icon]:hidden' // If the title starts with an emoji, don't show the icon (on desktop)
|
||||||
|
: '',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
|
|||||||
import { getAllBrowserCookiesMap } from '@/lib/browser';
|
import { getAllBrowserCookiesMap } from '@/lib/browser';
|
||||||
import { type CurrentContentContext, useCurrentContent } from '../hooks';
|
import { type CurrentContentContext, useCurrentContent } from '../hooks';
|
||||||
import { getSession } from './sessions';
|
import { getSession } from './sessions';
|
||||||
import { getVisitorId } from './visitorId';
|
import { type SessionResponse, useVisitorSession } from './visitorId';
|
||||||
|
|
||||||
export type InsightsEventName = api.SiteInsightsEvent['type'];
|
export type InsightsEventName = api.SiteInsightsEvent['type'];
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ export type InsightsEventName = api.SiteInsightsEvent['type'];
|
|||||||
* Context for an event on a page.
|
* Context for an event on a page.
|
||||||
*/
|
*/
|
||||||
export interface InsightsEventPageContext {
|
export interface InsightsEventPageContext {
|
||||||
|
displayContext: api.SiteInsightsDisplayContext;
|
||||||
pageId: string | null;
|
pageId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,18 +47,14 @@ type TrackEventCallback = <EventName extends InsightsEventName>(
|
|||||||
options?: InsightsEventOptions
|
options?: InsightsEventOptions
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
const InsightsContext = React.createContext<TrackEventCallback>(() => {});
|
const InsightsContext = React.createContext<TrackEventCallback>(() => {
|
||||||
|
console.error('useTrackEvent must be used within an InsightsProvider');
|
||||||
|
});
|
||||||
|
|
||||||
interface InsightsProviderProps {
|
interface InsightsProviderProps {
|
||||||
/** If true, the events will be sent to the server. */
|
/** If true, the events will be sent to the server. */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
||||||
/** If true, the visitor cookie tracking will be used */
|
|
||||||
visitorCookieTrackingEnabled: boolean;
|
|
||||||
|
|
||||||
/** The application URL. */
|
|
||||||
appURL: string;
|
|
||||||
|
|
||||||
/** The url of the endpoint to send events to */
|
/** The url of the endpoint to send events to */
|
||||||
eventUrl: string;
|
eventUrl: string;
|
||||||
|
|
||||||
@@ -69,10 +66,10 @@ interface InsightsProviderProps {
|
|||||||
* Wrap the content of the app with the InsightsProvider to track events.
|
* Wrap the content of the app with the InsightsProvider to track events.
|
||||||
*/
|
*/
|
||||||
export function InsightsProvider(props: InsightsProviderProps) {
|
export function InsightsProvider(props: InsightsProviderProps) {
|
||||||
const { enabled, children, visitorCookieTrackingEnabled, eventUrl, appURL } = props;
|
const { enabled, children, eventUrl } = props;
|
||||||
|
|
||||||
|
const visitorSession = useVisitorSession();
|
||||||
const currentContent = useCurrentContent();
|
const currentContent = useCurrentContent();
|
||||||
const visitorIdRef = React.useRef<string | null>(null);
|
|
||||||
const eventsRef = React.useRef<{
|
const eventsRef = React.useRef<{
|
||||||
[pathname: string]:
|
[pathname: string]:
|
||||||
| {
|
| {
|
||||||
@@ -89,9 +86,8 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
|||||||
*/
|
*/
|
||||||
const flushEventsSync = useEventCallback(() => {
|
const flushEventsSync = useEventCallback(() => {
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
const visitorId = visitorIdRef.current;
|
if (!visitorSession) {
|
||||||
if (!visitorId) {
|
return;
|
||||||
throw new Error('Visitor ID should be set before flushing events');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allEvents: api.SiteInsightsEvent[] = [];
|
const allEvents: api.SiteInsightsEvent[] = [];
|
||||||
@@ -111,7 +107,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
|||||||
events: eventsForPathname.events,
|
events: eventsForPathname.events,
|
||||||
context: currentContent,
|
context: currentContent,
|
||||||
pageContext: eventsForPathname.pageContext,
|
pageContext: eventsForPathname.pageContext,
|
||||||
visitorId,
|
visitorSession,
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -123,25 +119,25 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allEvents.length > 0) {
|
if (allEvents.length > 0 && enabled) {
|
||||||
if (enabled) {
|
sendEvents({
|
||||||
sendEvents({
|
eventUrl,
|
||||||
eventUrl,
|
events: allEvents,
|
||||||
events: allEvents,
|
});
|
||||||
});
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const flushBatchedEvents = useDebounceCallback(async () => {
|
const flushBatchedEvents = useDebounceCallback(() => {
|
||||||
const visitorId =
|
|
||||||
visitorIdRef.current ?? (await getVisitorId(appURL, visitorCookieTrackingEnabled));
|
|
||||||
visitorIdRef.current = visitorId;
|
|
||||||
|
|
||||||
flushEventsSync();
|
flushEventsSync();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
||||||
|
// Flush pending events once the visitor session has been fetched
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (visitorSession) {
|
||||||
|
flushEventsSync();
|
||||||
|
}
|
||||||
|
}, [visitorSession, flushEventsSync]);
|
||||||
|
|
||||||
const trackEvent: TrackEventCallback = useEventCallback(
|
const trackEvent: TrackEventCallback = useEventCallback(
|
||||||
(
|
(
|
||||||
event: TrackEventInput<InsightsEventName>,
|
event: TrackEventInput<InsightsEventName>,
|
||||||
@@ -166,7 +162,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
|||||||
if (eventsRef.current[pathname].pageContext !== undefined) {
|
if (eventsRef.current[pathname].pageContext !== undefined) {
|
||||||
// If the pageId is set, we know that the page_view event has been tracked
|
// If the pageId is set, we know that the page_view event has been tracked
|
||||||
// and we can flush the events
|
// and we can flush the events
|
||||||
if (options?.immediate && visitorIdRef.current) {
|
if (options?.immediate) {
|
||||||
flushBatchedEvents.cancel();
|
flushBatchedEvents.cancel();
|
||||||
flushEventsSync();
|
flushEventsSync();
|
||||||
} else {
|
} else {
|
||||||
@@ -177,18 +173,14 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the visitor ID and store it in a ref.
|
* When the page is unloaded, flush all events.
|
||||||
*/
|
*/
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
getVisitorId(appURL, visitorCookieTrackingEnabled).then((visitorId) => {
|
window.addEventListener('beforeunload', flushEventsSync);
|
||||||
visitorIdRef.current = visitorId;
|
|
||||||
// When the page is unloaded, flush all events, but only if the visitor ID is set
|
|
||||||
window.addEventListener('beforeunload', flushEventsSync);
|
|
||||||
});
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('beforeunload', flushEventsSync);
|
window.removeEventListener('beforeunload', flushEventsSync);
|
||||||
};
|
};
|
||||||
}, [flushEventsSync, visitorCookieTrackingEnabled, appURL]);
|
}, [flushEventsSync]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InsightsContext.Provider value={trackEvent}>
|
<InsightsContext.Provider value={trackEvent}>
|
||||||
@@ -242,12 +234,12 @@ function transformEvents(input: {
|
|||||||
events: TrackEventInput<InsightsEventName>[];
|
events: TrackEventInput<InsightsEventName>[];
|
||||||
context: CurrentContentContext;
|
context: CurrentContentContext;
|
||||||
pageContext: InsightsEventPageContext;
|
pageContext: InsightsEventPageContext;
|
||||||
visitorId: string;
|
visitorSession: SessionResponse;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}): api.SiteInsightsEvent[] {
|
}): api.SiteInsightsEvent[] {
|
||||||
const session: api.SiteInsightsEventSession = {
|
const session: api.SiteInsightsEventSession = {
|
||||||
sessionId: input.sessionId,
|
sessionId: input.sessionId,
|
||||||
visitorId: input.visitorId,
|
visitorId: input.visitorSession.deviceId,
|
||||||
userAgent: window.navigator.userAgent,
|
userAgent: window.navigator.userAgent,
|
||||||
language: window.navigator.language,
|
language: window.navigator.language,
|
||||||
cookies: getAllBrowserCookiesMap(),
|
cookies: getAllBrowserCookiesMap(),
|
||||||
@@ -263,6 +255,7 @@ function transformEvents(input: {
|
|||||||
siteShareKey: input.context.siteShareKey ?? null,
|
siteShareKey: input.context.siteShareKey ?? null,
|
||||||
revision: input.context.revisionId,
|
revision: input.context.revisionId,
|
||||||
page: input.pageContext.pageId,
|
page: input.pageContext.pageId,
|
||||||
|
displayContext: input.pageContext.displayContext,
|
||||||
};
|
};
|
||||||
|
|
||||||
return input.events.map((partialEvent) => {
|
return input.events.map((partialEvent) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import type { SiteInsightsDisplayContext } from '@gitbook/api';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { useCurrentPage } from '../hooks';
|
import { useCurrentPage } from '../hooks';
|
||||||
@@ -8,7 +9,10 @@ import { useTrackEvent } from './InsightsProvider';
|
|||||||
/**
|
/**
|
||||||
* Track a page view event.
|
* Track a page view event.
|
||||||
*/
|
*/
|
||||||
export function TrackPageViewEvent() {
|
export function TrackPageViewEvent(props: {
|
||||||
|
displayContext: SiteInsightsDisplayContext;
|
||||||
|
}) {
|
||||||
|
const { displayContext } = props;
|
||||||
const page = useCurrentPage();
|
const page = useCurrentPage();
|
||||||
const trackEvent = useTrackEvent();
|
const trackEvent = useTrackEvent();
|
||||||
|
|
||||||
@@ -19,9 +23,10 @@ export function TrackPageViewEvent() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
pageId: page?.pageId ?? null,
|
pageId: page?.pageId ?? null,
|
||||||
|
displayContext,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}, [page, trackEvent]);
|
}, [page, trackEvent, displayContext]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { getBrowserCookie } from '@/lib/browser';
|
|
||||||
|
|
||||||
import { isCookiesTrackingDisabled } from './cookies';
|
|
||||||
import { generateRandomId } from './utils';
|
|
||||||
|
|
||||||
const VISITORID_COOKIE = '__session';
|
|
||||||
|
|
||||||
let visitorId: string | null = null;
|
|
||||||
let pendingVisitorId: Promise<string> | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the current visitor identifier.
|
|
||||||
*/
|
|
||||||
export async function getVisitorId(
|
|
||||||
appURL: string,
|
|
||||||
visitorCookieTrackingEnabled: boolean
|
|
||||||
): Promise<string> {
|
|
||||||
if (!visitorId) {
|
|
||||||
if (!pendingVisitorId) {
|
|
||||||
pendingVisitorId = fetchVisitorID(appURL, visitorCookieTrackingEnabled).finally(() => {
|
|
||||||
pendingVisitorId = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
visitorId = await pendingVisitorId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return visitorId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
|
|
||||||
*/
|
|
||||||
async function fetchVisitorID(
|
|
||||||
appURL: string,
|
|
||||||
visitorCookieTrackingEnabled: boolean
|
|
||||||
): Promise<string> {
|
|
||||||
const withoutCookies = isCookiesTrackingDisabled();
|
|
||||||
|
|
||||||
if (withoutCookies || !visitorCookieTrackingEnabled) {
|
|
||||||
return generateRandomId();
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingTrackingCookie = getBrowserCookie(VISITORID_COOKIE);
|
|
||||||
|
|
||||||
if (existingTrackingCookie) {
|
|
||||||
// If the cookie already exists, we'll just use that. Avoids a server request.
|
|
||||||
return existingTrackingCookie;
|
|
||||||
}
|
|
||||||
// No tracking deviceId set, we'll need to consolidate with the server.
|
|
||||||
const proposed = generateRandomId();
|
|
||||||
|
|
||||||
const url = new URL(appURL);
|
|
||||||
url.pathname = '/__session/2/';
|
|
||||||
url.searchParams.set('proposed', proposed);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch(url, {
|
|
||||||
method: 'GET', // Use GET to play nicely with SameSite cookies.
|
|
||||||
credentials: 'include', // Make sure to send/receive cookies.
|
|
||||||
cache: 'no-cache',
|
|
||||||
mode: 'cors', // Need to use cors as we are on a different domain.
|
|
||||||
});
|
|
||||||
|
|
||||||
const { deviceId } = (await resp.json()) as { deviceId: string };
|
|
||||||
return deviceId;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch visitor session ID', error);
|
|
||||||
return proposed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user