mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-11-02 10:13:46 +00:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af11d23c7f | |||
| 2c3066e3ae | |||
| 55c0b03a41 | |||
| 3e548e4186 | |||
| 3aea302648 | |||
| 1d1f0a7c6a | |||
| 590729cce5 | |||
| b554d7d13f | |||
| 9568b8a7c8 | |||
| 683bb8f634 | |||
| 7d6a31627f | |||
| c31e5648f5 | |||
| 0c01832684 | |||
| 6b1c9fef86 | |||
| cc976995cc | |||
| ebbb476589 | |||
| 89655cb428 | |||
| 3676d64bdc | |||
| f682df1e01 | |||
| 6c9893945d | |||
| a2fb627ae1 | |||
| f9f80117f0 | |||
| 63bf4dbb16 | |||
| b3fa503aff | |||
| 0ae7cfd790 | |||
| 8fdc5c89dc | |||
| 758b34d46b | |||
| 1b0613e482 | |||
| 3164f1a99d | |||
| 6b588af07d | |||
| d436b04ff4 | |||
| d159788d0b | |||
| 3355960d95 | |||
| c32d4494f2 | |||
| 1dcf4df51f | |||
| 3c0e49d56b | |||
| c35d0e8954 | |||
| c0d73d2f76 | |||
| 454175ab86 | |||
| 6feb8fcd6a | |||
| b8d3cb7ee5 | |||
| 8ce73229c3 | |||
| 8eae6e970b | |||
| a48b886dfd | |||
| 17de0838c5 | |||
| baf3496980 |
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix webframe height issue
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
SearchInput fixes — Translate Clear button, Bold theme color, "Esc" keyboard shortcut
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix search container shadow
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Revert contentref anchor title
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Move the "Ask <query>" to the bottom of search results if query is not a question
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve AI Search branding
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
'@gitbook/react-openapi': patch
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add alt text support to card covers
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve ancestor label in references
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Tweak language dropdown styling
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix ToggeableLinkItem display
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix icon resolution for page group
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Support translations and generic variants together
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add confirmation to "Copy MCP URL" page action
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Update search scope UI
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix OpenAPI path display
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
'@gitbook/react-openapi': patch
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add OpenAPI servers selection
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
'@gitbook/react-openapi': patch
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve OAuth2 scopes handling in OpenAPI
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix opacity of ScrollContainer buttons on bold themes
|
||||||
@@ -54,10 +54,9 @@ After forking this repository, you'll want to [create a branch](https://docs.git
|
|||||||
#### 3. Install dependencies and run the project locally
|
#### 3. Install dependencies and run the project locally
|
||||||
|
|
||||||
##### Prerequisites:
|
##### Prerequisites:
|
||||||
- Node.js (Version: >= 22.3)
|
|
||||||
- Use `nvm` for easy Node management
|
- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`)
|
||||||
- [Bun](https://bun.sh/) (Version: >=1.2.15)
|
- [Bun](https://bun.sh/) (see "packageManager" in `package.json`)
|
||||||
- We use a text-based lockfile which isn't supported below 1.2.15
|
|
||||||
|
|
||||||
##### Setup steps:
|
##### Setup steps:
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- changeset-release/main
|
||||||
env:
|
env:
|
||||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
+13
-3
@@ -1,15 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.3.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@changesets/cli": "^2.29.7",
|
"@changesets/cli": "^2.29.7",
|
||||||
"turbo": "^2.5.8",
|
"turbo": "^2.5.8",
|
||||||
"vercel": "^39.4.2"
|
"vercel": "^39.4.2"
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.2.15",
|
"packageManager": "bun@1.3.0",
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@codemirror/state": "6.4.1",
|
"@codemirror/state": "6.4.1",
|
||||||
|
"@types/react": "catalog:",
|
||||||
|
"@types/react-dom": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"esbuild": "0.24.2"
|
"esbuild": "0.24.2"
|
||||||
@@ -35,7 +40,12 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": ["packages/*"],
|
"packages": ["packages/*"],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@gitbook/api": "0.143.2",
|
"@tsconfig/strictest": "^2.0.6",
|
||||||
|
"@tsconfig/node20": "^20.1.6",
|
||||||
|
"@gitbook/api": "0.146.0",
|
||||||
|
"@scalar/api-client-react": "^1.3.46",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
"assert-never": "^1.4.0",
|
"assert-never": "^1.4.0",
|
||||||
"bidc": "^0.0.2",
|
"bidc": "^0.0.2",
|
||||||
"bun-types": "^1.1.20",
|
"bun-types": "^1.1.20",
|
||||||
@@ -44,7 +54,7 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"tsdown": "^0.15.6",
|
"tsdown": "^0.15.6",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"usehooks-ts": "^3.1.0"
|
"usehooks-ts": "^3.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@gitbook/icons": "workspace:"
|
"@gitbook/icons": "workspace:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"bun-types": "catalog:",
|
||||||
"tsdown": "catalog:",
|
"tsdown": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"assert-never": "catalog:"
|
"assert-never": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"bun-types": "catalog:",
|
||||||
"tsdown": "catalog:",
|
"tsdown": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"version": "0.4.2",
|
"version": "0.4.2",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"bun-types": "catalog:",
|
||||||
"tsdown": "catalog:",
|
"tsdown": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"react": "*"
|
"react": "*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/react": "catalog:",
|
||||||
"tsdown": "catalog:",
|
"tsdown": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"react": "catalog:"
|
"react": "catalog:"
|
||||||
|
|||||||
@@ -19,11 +19,14 @@
|
|||||||
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#9cf28d2"
|
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#9cf28d2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tsconfig/strictest": "catalog:",
|
||||||
|
"@tsconfig/node20": "catalog:",
|
||||||
"@types/escodegen": "^0.0.10",
|
"@types/escodegen": "^0.0.10",
|
||||||
"@types/estree": "^1.0.6",
|
"@types/estree": "^1.0.6",
|
||||||
"@types/json-schema": "^7.0.15",
|
"@types/json-schema": "^7.0.15",
|
||||||
"bun-types": "catalog:",
|
"bun-types": "catalog:",
|
||||||
"tsdown": "catalog:"
|
"tsdown": "catalog:",
|
||||||
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsdown",
|
"build": "tsdown",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@gitbook/api": "catalog:"
|
"@gitbook/api": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"bun-types": "catalog:",
|
||||||
"google-font-metadata": "^6.0.7",
|
"google-font-metadata": "^6.0.7",
|
||||||
"tsdown": "catalog:",
|
"tsdown": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
|
|||||||
@@ -112,11 +112,11 @@ const testCases: TestsCase[] = [
|
|||||||
contentBaseURL: 'https://adiblar.gitbook.io',
|
contentBaseURL: 'https://adiblar.gitbook.io',
|
||||||
tests: [{ name: 'Home', url: '/' }],
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
name: 'docs.gradient.network',
|
// name: 'docs.gradient.network',
|
||||||
contentBaseURL: 'https://docs.gradient.network',
|
// contentBaseURL: 'https://docs.gradient.network',
|
||||||
tests: [{ name: 'Home', url: '/' }],
|
// tests: [{ name: 'Home', url: '/' }],
|
||||||
},
|
// },
|
||||||
// {
|
// {
|
||||||
// name: 'mygate-network.gitbook.io',
|
// name: 'mygate-network.gitbook.io',
|
||||||
// contentBaseURL: 'https://mygate-network.gitbook.io',
|
// contentBaseURL: 'https://mygate-network.gitbook.io',
|
||||||
|
|||||||
@@ -228,6 +228,58 @@ const testCases: TestsCase[] = [
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Expandable TOC navigation',
|
||||||
|
url: '',
|
||||||
|
run: async (page) => {
|
||||||
|
await waitForCookiesDialog(page);
|
||||||
|
|
||||||
|
// Verify "Navigation" link is not visible initially
|
||||||
|
const navigationLink = page.getByRole('link', { name: 'Navigation' });
|
||||||
|
await expect(navigationLink).not.toBeVisible();
|
||||||
|
|
||||||
|
// Find and click the chevron element that is next to "Editor" in the TOC
|
||||||
|
// It is a span inside the link
|
||||||
|
const editorChevron = page
|
||||||
|
.getByRole('link', { name: 'Editor' })
|
||||||
|
.locator('span');
|
||||||
|
await editorChevron.click();
|
||||||
|
|
||||||
|
// Verify "Navigation" link becomes visible after expansion
|
||||||
|
await expect(navigationLink).toBeVisible();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Expandable nested TOC navigation',
|
||||||
|
url: '',
|
||||||
|
screenshot: false,
|
||||||
|
run: async (page) => {
|
||||||
|
await waitForCookiesDialog(page);
|
||||||
|
|
||||||
|
// Verify "Spaces" link is not visible initially
|
||||||
|
const navigationLink = page.getByRole('link', { name: 'Spaces' });
|
||||||
|
await expect(navigationLink).not.toBeVisible();
|
||||||
|
|
||||||
|
// Find and click the chevron element that is next to "Editor" in the TOC
|
||||||
|
// It is a span inside the link
|
||||||
|
const editorChevron = page
|
||||||
|
.getByRole('link', { name: 'Editor' })
|
||||||
|
.locator('span');
|
||||||
|
await editorChevron.click();
|
||||||
|
|
||||||
|
// At this stage the link should still not be visible
|
||||||
|
await expect(navigationLink).not.toBeVisible();
|
||||||
|
|
||||||
|
// Then we click 'Content Structure' chevron to expand further
|
||||||
|
const contentStructureChevron = page
|
||||||
|
.getByRole('link', { name: 'Content Structure' })
|
||||||
|
.locator('span');
|
||||||
|
await contentStructureChevron.click();
|
||||||
|
|
||||||
|
// Verify "Spaces" link becomes visible after expansion
|
||||||
|
await expect(navigationLink).toBeVisible();
|
||||||
|
},
|
||||||
|
},
|
||||||
...searchTestCases,
|
...searchTestCases,
|
||||||
{
|
{
|
||||||
name: 'Not found',
|
name: 'Not found',
|
||||||
@@ -727,7 +779,10 @@ const testCases: TestsCase[] = [
|
|||||||
url: 'blocks/integrations',
|
url: 'blocks/integrations',
|
||||||
run: async (page) => {
|
run: async (page) => {
|
||||||
await waitForCookiesDialog(page);
|
await waitForCookiesDialog(page);
|
||||||
const mermaidIframe = page.locator('iframe[title*="mermaid"]').contentFrame();
|
const mermaidIframe = page
|
||||||
|
.locator('iframe[title*="mermaid"]')
|
||||||
|
.first()
|
||||||
|
.contentFrame();
|
||||||
await expect(mermaidIframe.getByText('Mermaid', { exact: true })).toBeVisible();
|
await expect(mermaidIframe.getByText('Mermaid', { exact: true })).toBeVisible();
|
||||||
await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible();
|
await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible();
|
||||||
},
|
},
|
||||||
@@ -1454,10 +1509,7 @@ const testCases: TestsCase[] = [
|
|||||||
locale,
|
locale,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
run: async (page) => {
|
run: waitForCookiesDialog,
|
||||||
const dialog = page.getByTestId('cookies-dialog');
|
|
||||||
await expect(dialog).toBeVisible();
|
|
||||||
},
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -143,7 +143,10 @@ export const headerLinks: CustomizationHeaderItem[] = [
|
|||||||
|
|
||||||
export async function waitForCookiesDialog(page: Page) {
|
export async function waitForCookiesDialog(page: Page) {
|
||||||
const dialog = page.getByTestId('cookies-dialog');
|
const dialog = page.getByTestId('cookies-dialog');
|
||||||
await expect(dialog).toBeVisible();
|
await expect(dialog).toBeVisible({
|
||||||
|
// Cookies dialog may take some times to appear
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForNotFound(_page: Page, response: Response | null) {
|
export async function waitForNotFound(_page: Page, response: Response | null) {
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export default {
|
|||||||
converter: 'edge',
|
converter: 'edge',
|
||||||
proxyExternalRequest: 'fetch',
|
proxyExternalRequest: 'fetch',
|
||||||
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
|
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
|
||||||
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
|
incrementalCache: () =>
|
||||||
|
import('./openNext/incrementalCache/server').then((m) => m.default),
|
||||||
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
|
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -18,7 +19,8 @@ export default {
|
|||||||
converter: 'edge',
|
converter: 'edge',
|
||||||
proxyExternalRequest: 'fetch',
|
proxyExternalRequest: 'fetch',
|
||||||
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
|
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
|
||||||
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
|
incrementalCache: () =>
|
||||||
|
import('./openNext/incrementalCache/middleware').then((m) => m.default),
|
||||||
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
|
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"main": "default.js",
|
"main": "default.js",
|
||||||
"name": "gitbook-open-v2-server",
|
"name": "gitbook-open-v2-server",
|
||||||
|
"keep_names": false,
|
||||||
"compatibility_date": "2025-04-14",
|
"compatibility_date": "2025-04-14",
|
||||||
"compatibility_flags": [
|
"compatibility_flags": [
|
||||||
"nodejs_compat",
|
"nodejs_compat",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"main": "do.js",
|
"main": "do.js",
|
||||||
"name": "gitbook-open-v2-do",
|
"name": "gitbook-open-v2-do",
|
||||||
"compatibility_date": "2025-04-14",
|
"compatibility_date": "2025-04-14",
|
||||||
|
"keep_names": false,
|
||||||
"compatibility_flags": [
|
"compatibility_flags": [
|
||||||
"nodejs_compat",
|
"nodejs_compat",
|
||||||
"allow_importable_env",
|
"allow_importable_env",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"main": "middleware.js",
|
"main": "middleware.js",
|
||||||
"name": "gitbook-open-v2",
|
"name": "gitbook-open-v2",
|
||||||
"compatibility_date": "2025-04-14",
|
"compatibility_date": "2025-04-14",
|
||||||
|
"keep_names": false,
|
||||||
"compatibility_flags": [
|
"compatibility_flags": [
|
||||||
"nodejs_compat",
|
"nodejs_compat",
|
||||||
"allow_importable_env",
|
"allow_importable_env",
|
||||||
|
|||||||
+1
-12
@@ -7,8 +7,6 @@ import type {
|
|||||||
} from '@opennextjs/aws/types/overrides.js';
|
} from '@opennextjs/aws/types/overrides.js';
|
||||||
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
||||||
|
|
||||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
|
||||||
|
|
||||||
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
|
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
|
||||||
|
|
||||||
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
|
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
|
||||||
@@ -23,7 +21,7 @@ export type KeyOptions = {
|
|||||||
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it has an additional
|
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it has an additional
|
||||||
* R2WriteBuffer Durable Object to handle writes to R2. Given how we set up cache, we often end up writing to the same key too fast.
|
* R2WriteBuffer Durable Object to handle writes to R2. Given how we set up cache, we often end up writing to the same key too fast.
|
||||||
*/
|
*/
|
||||||
class GitbookIncrementalCache implements IncrementalCache {
|
export class GitbookIncrementalCache implements IncrementalCache {
|
||||||
name = 'GitbookIncrementalCache';
|
name = 'GitbookIncrementalCache';
|
||||||
|
|
||||||
async get<CacheType extends CacheEntryType = 'cache'>(
|
async get<CacheType extends CacheEntryType = 'cache'>(
|
||||||
@@ -137,12 +135,3 @@ class GitbookIncrementalCache implements IncrementalCache {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRegionalCache(new GitbookIncrementalCache(), {
|
|
||||||
mode: 'long-lived',
|
|
||||||
// We can do it because we use our own logic to invalidate the cache
|
|
||||||
bypassTagCacheOnCacheHit: true,
|
|
||||||
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
|
||||||
// We don't want to update the cache entry on every cache hit
|
|
||||||
shouldLazilyUpdateOnCacheHit: false,
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
||||||
|
import { GitbookIncrementalCache } from './incrementalCache';
|
||||||
|
|
||||||
|
export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||||
|
mode: 'long-lived',
|
||||||
|
// We can do it because we use our own logic to invalidate the cache
|
||||||
|
bypassTagCacheOnCacheHit: true,
|
||||||
|
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||||
|
// We don't want to update the cache entry on every cache hit
|
||||||
|
shouldLazilyUpdateOnCacheHit: false,
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
||||||
|
import { GitbookIncrementalCache } from './incrementalCache';
|
||||||
|
|
||||||
|
export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||||
|
mode: 'long-lived',
|
||||||
|
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
|
||||||
|
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
|
||||||
|
bypassTagCacheOnCacheHit: false,
|
||||||
|
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||||
|
// We don't want to update the cache entry on every cache hit
|
||||||
|
shouldLazilyUpdateOnCacheHit: false,
|
||||||
|
});
|
||||||
@@ -3,12 +3,14 @@
|
|||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20251011.0",
|
||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
"@gitbook/browser-types": "workspace:*",
|
"@gitbook/browser-types": "workspace:*",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@gitbook/colors": "workspace:*",
|
"@gitbook/colors": "workspace:*",
|
||||||
"@gitbook/emoji-codepoints": "workspace:*",
|
"@gitbook/emoji-codepoints": "workspace:*",
|
||||||
"@gitbook/expr": "workspace:*",
|
"@gitbook/expr": "workspace:*",
|
||||||
|
"@gitbook/embed": "workspace:*",
|
||||||
"@gitbook/fonts": "workspace:*",
|
"@gitbook/fonts": "workspace:*",
|
||||||
"@gitbook/icons": "workspace:*",
|
"@gitbook/icons": "workspace:*",
|
||||||
"@gitbook/openapi-parser": "workspace:*",
|
"@gitbook/openapi-parser": "workspace:*",
|
||||||
@@ -16,7 +18,8 @@
|
|||||||
"@gitbook/react-math": "workspace:*",
|
"@gitbook/react-math": "workspace:*",
|
||||||
"@gitbook/react-openapi": "workspace:*",
|
"@gitbook/react-openapi": "workspace:*",
|
||||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||||
"@opennextjs/cloudflare": "^1.9.2",
|
"@opennextjs/aws": "^3.8.5",
|
||||||
|
"@opennextjs/cloudflare": "^1.11.0",
|
||||||
"@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",
|
||||||
@@ -33,12 +36,10 @@
|
|||||||
"classnames": "catalog:",
|
"classnames": "catalog:",
|
||||||
"direction": "^2.0.1",
|
"direction": "^2.0.1",
|
||||||
"event-iterator": "^2.0.0",
|
"event-iterator": "^2.0.0",
|
||||||
"framer-motion": "^10.16.14",
|
|
||||||
"image-size": "^2.0.2",
|
"image-size": "^2.0.2",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jsontoxml": "^1.0.1",
|
"jsontoxml": "^1.0.1",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"mathjax": "^3.2.2",
|
|
||||||
"mcp-handler": "^1.0.2",
|
"mcp-handler": "^1.0.2",
|
||||||
"mdast-util-from-markdown": "^2.0.2",
|
"mdast-util-from-markdown": "^2.0.2",
|
||||||
"mdast-util-frontmatter": "^2.0.1",
|
"mdast-util-frontmatter": "^2.0.1",
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
"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",
|
||||||
"motion": "^12.23.12",
|
"motion": "^12.23.24",
|
||||||
"next": "15.4.0",
|
"next": "15.4.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nuqs": "^2.2.3",
|
"nuqs": "^2.2.3",
|
||||||
@@ -56,6 +57,8 @@
|
|||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"p-map": "^7.0.3",
|
"p-map": "^7.0.3",
|
||||||
"quick-lru": "^7.0.1",
|
"quick-lru": "^7.0.1",
|
||||||
|
"react": "catalog:",
|
||||||
|
"react-dom": "catalog:",
|
||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"rehype-stringify": "^10.0.1",
|
"rehype-stringify": "^10.0.1",
|
||||||
@@ -79,6 +82,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@argos-ci/playwright": "^5.0.9",
|
"@argos-ci/playwright": "^5.0.9",
|
||||||
"@playwright/test": "^1.54.2",
|
"@playwright/test": "^1.54.2",
|
||||||
|
"@scalar/api-client-react": "catalog:",
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/jsontoxml": "^1.0.5",
|
"@types/jsontoxml": "^1.0.5",
|
||||||
@@ -87,8 +91,8 @@
|
|||||||
"@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/react": "18.3.13",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "catalog:",
|
||||||
"@types/rison": "^0.0.9",
|
"@types/rison": "^0.0.9",
|
||||||
"bun-types": "catalog:",
|
"bun-types": "catalog:",
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
@@ -99,7 +103,8 @@
|
|||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"ts-essentials": "^10.0.1",
|
"ts-essentials": "^10.0.1",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vercel": "^39.3.0"
|
"vercel": "^39.3.0",
|
||||||
|
"wrangler": "^4.43.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "./scripts/generate.sh",
|
"generate": "./scripts/generate.sh",
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { CustomizationAIMode } from '@gitbook/api';
|
import { CustomizationAIMode } from '@gitbook/api';
|
||||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
import { Icon, type IconName } from '@gitbook/icons';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { tString, useLanguage } from '@/intl/client';
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
import type { GitBookAssistant } from '@gitbook/browser-types';
|
import type { GitBookAssistant } from '@gitbook/browser-types';
|
||||||
import { useAIChatController, useAIChatState } from '.';
|
import { useAIChatController, useAIChatState } from '.';
|
||||||
import { AIChatIcon, getAIChatName } from '../AIChat';
|
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
|
||||||
import { useIntegrationAssistants } from '../Integrations';
|
import { useIntegrationAssistants } from '../Integrations';
|
||||||
import { useSearch } from '../Search/useSearch';
|
import { useSearch } from '../Search/useSearch';
|
||||||
|
|
||||||
@@ -104,16 +104,7 @@ export function useAI(): AIContext {
|
|||||||
assistants.push({
|
assistants.push({
|
||||||
id: 'gitbook-ai-search',
|
id: 'gitbook-ai-search',
|
||||||
label: tString(language, 'ai_chat_context_badge'),
|
label: tString(language, 'ai_chat_context_badge'),
|
||||||
icon: (
|
icon: <AISearchIcon />,
|
||||||
<div className="relative">
|
|
||||||
<Icon icon="search" className="size-4" />
|
|
||||||
<Icon
|
|
||||||
icon="sparkle"
|
|
||||||
iconStyle={IconStyle.Solid}
|
|
||||||
className="absolute top-[2.5px] left-[2.6px] size-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
open: (query?: string) => {
|
open: (query?: string) => {
|
||||||
if (query) {
|
if (query) {
|
||||||
setSearchState((prev) =>
|
setSearchState((prev) =>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon, IconStyle } from '@gitbook/icons';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
|
||||||
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
||||||
@@ -9,17 +9,18 @@ interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
|||||||
trademark?: boolean;
|
trademark?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AIChatIcon = ({
|
export function AIChatIcon({
|
||||||
className = 'size-4',
|
className = 'size-4',
|
||||||
size,
|
size,
|
||||||
trademark = true,
|
trademark = true,
|
||||||
state = 'default',
|
state = 'default',
|
||||||
...props
|
...props
|
||||||
}: AIChatIconProps) => {
|
}: AIChatIconProps) {
|
||||||
if (!trademark) {
|
if (!trademark) {
|
||||||
return (
|
return (
|
||||||
<Icon
|
<Icon
|
||||||
icon="sparkle"
|
icon="sparkle"
|
||||||
|
{...props}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
className,
|
className,
|
||||||
(state === 'thinking' || state === 'working') &&
|
(state === 'thinking' || state === 'working') &&
|
||||||
@@ -163,4 +164,36 @@ export const AIChatIcon = ({
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
export function AISearchIcon({
|
||||||
|
className = 'size-4',
|
||||||
|
state = 'default',
|
||||||
|
}: Pick<AIChatIconProps, 'className' | 'state'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'relative',
|
||||||
|
state === 'intro' && 'animate-[fadeIn_1s_both,orbit_1s_cubic-bezier(0.16,1,0.3,1)]',
|
||||||
|
state === 'thinking' || state === 'working'
|
||||||
|
? 'animate-[fadeIn_1s_both,orbit_1s_ease-out,orbit_2s_1s_infinite_forwards_linear]'
|
||||||
|
: ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon="search" className={className} />
|
||||||
|
<Icon
|
||||||
|
icon="sparkle"
|
||||||
|
iconStyle={IconStyle.Solid}
|
||||||
|
className={tcls(
|
||||||
|
'absolute top-[15.7%] left-[15.6%] size-[50%]',
|
||||||
|
state === 'thinking' || state === 'working'
|
||||||
|
? 'animate-[spin_2s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]'
|
||||||
|
: '',
|
||||||
|
state === 'intro'
|
||||||
|
? 'animate-[spin_2s_.5s_forwards_cubic-bezier(0.16,1,0.3,1)]'
|
||||||
|
: ''
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ 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 | null>;
|
||||||
}) {
|
}) {
|
||||||
const { chat, chatController, lastUserMessageRef } = props;
|
const { chat, chatController, lastUserMessageRef } = props;
|
||||||
|
|
||||||
@@ -81,11 +81,19 @@ export function AIChatMessages(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HoldMessage() {
|
export function HoldMessage({
|
||||||
|
breakLines = false,
|
||||||
|
className = '',
|
||||||
|
}: { breakLines?: boolean; className?: string }) {
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-[heightIn_500ms_4500ms_ease_both] py-2 text-tint-subtle">
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'animate-[heightIn_500ms_4500ms_ease_both] overflow-hidden py-2 text-tint-subtle',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
{tString(language, 'ai_chat_hold_message_1')
|
{tString(language, 'ai_chat_hold_message_1')
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map((word, index) => (
|
.map((word, index) => (
|
||||||
@@ -99,6 +107,7 @@ function HoldMessage() {
|
|||||||
{word}{' '}
|
{word}{' '}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
{breakLines ? <br /> : null}
|
||||||
{tString(language, 'ai_chat_hold_message_2')
|
{tString(language, 'ai_chat_hold_message_2')
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map((word, index) => (
|
.map((word, index) => (
|
||||||
|
|||||||
@@ -167,10 +167,10 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
|
|||||||
icon="comment"
|
icon="comment"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Open production site */}
|
{/* Open published/live site */}
|
||||||
{site.urls.published ? (
|
{site.urls.published ? (
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
title="Open production site"
|
title="Open live site"
|
||||||
href={getToolbarHref({
|
href={getToolbarHref({
|
||||||
href: site.urls.published,
|
href: site.urls.published,
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
@@ -249,7 +249,7 @@ function RevisionToolbar(props: ToolbarViewProps) {
|
|||||||
/>
|
/>
|
||||||
{site.urls.published ? (
|
{site.urls.published ? (
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
title="Open production site"
|
title="Open live site"
|
||||||
href={getToolbarHref({
|
href={getToolbarHref({
|
||||||
href: site.urls.published,
|
href: site.urls.published,
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
|
|||||||
}
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
// @ts-expect-error wrong type for ref
|
||||||
useOnClickOutside(ref, handleClickOutsideArcMenu);
|
useOnClickOutside(ref, handleClickOutsideArcMenu);
|
||||||
|
|
||||||
// Close arc menu on scroll
|
// Close arc menu on scroll
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
useReducedMotion,
|
useReducedMotion,
|
||||||
useSpring,
|
useSpring,
|
||||||
} from 'motion/react';
|
} from 'motion/react';
|
||||||
import React from 'react';
|
import React, { isValidElement } from 'react';
|
||||||
import { AnimatedLogo } from './AnimatedLogo';
|
import { AnimatedLogo } from './AnimatedLogo';
|
||||||
import { useToolbarControls } from './ToolbarControlsContext';
|
import { useToolbarControls } from './ToolbarControlsContext';
|
||||||
|
|
||||||
@@ -291,6 +291,10 @@ function ToolbarButtonWrapper(props: {
|
|||||||
x: springX,
|
x: springX,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!isValidElement<{ motionValues: typeof motionValues }>(child)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return React.cloneElement(child, {
|
return React.cloneElement(child, {
|
||||||
motionValues,
|
motionValues,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ const calculateSpacing = (
|
|||||||
|
|
||||||
export function useMagnificationEffect(props: {
|
export function useMagnificationEffect(props: {
|
||||||
childrenCount: number;
|
childrenCount: number;
|
||||||
containerRef: React.RefObject<HTMLElement>;
|
containerRef: React.RefObject<HTMLElement | null>;
|
||||||
config?: MagnificationConfig;
|
config?: MagnificationConfig;
|
||||||
}) {
|
}) {
|
||||||
const { childrenCount, containerRef, config } = props;
|
const { childrenCount, containerRef, config } = props;
|
||||||
|
|||||||
@@ -51,11 +51,13 @@ type UnwrappedBlocksProps<TBlock extends DocumentBlock> = DocumentContextProps &
|
|||||||
* Ideally we'd rely on the block type to determine if it can be full width, but
|
* Ideally we'd rely on the block type to determine if it can be full width, but
|
||||||
* the block's `fullWidth` property does not differentiate between `undefined` and `false`.
|
* the block's `fullWidth` property does not differentiate between `undefined` and `false`.
|
||||||
* So instead we hardcode a list of blocks that can be full width. */
|
* So instead we hardcode a list of blocks that can be full width. */
|
||||||
const FULL_WIDTH_BLOCKS = [
|
const FULL_WIDTH_BLOCKS: DocumentBlock['type'][] = [
|
||||||
'table',
|
'table',
|
||||||
'tabs',
|
'tabs',
|
||||||
'integration',
|
'integration',
|
||||||
'openapi',
|
'openapi-operation',
|
||||||
|
'openapi-schemas',
|
||||||
|
'openapi-webhook',
|
||||||
'images',
|
'images',
|
||||||
'embed',
|
'embed',
|
||||||
'columns',
|
'columns',
|
||||||
@@ -64,6 +66,8 @@ const FULL_WIDTH_BLOCKS = [
|
|||||||
'hint',
|
'hint',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const LIST_BLOCKS: DocumentBlock['type'][] = ['list-ordered', 'list-tasks', 'list-unordered'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a list of blocks without a wrapper element.
|
* Renders a list of blocks without a wrapper element.
|
||||||
*/
|
*/
|
||||||
@@ -85,10 +89,11 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
|
|||||||
key={node.key || `${node.type}-${index}`}
|
key={node.key || `${node.type}-${index}`}
|
||||||
block={node}
|
block={node}
|
||||||
style={[
|
style={[
|
||||||
'mx-auto page-width-wide:mx-0 w-full decoration-primary/6 print:break-inside-avoid',
|
'mx-auto page-width-wide:mx-0 w-full decoration-primary/6',
|
||||||
node.data && 'fullWidth' in node.data && node.data.fullWidth
|
node.data && 'fullWidth' in node.data && node.data.fullWidth
|
||||||
? 'max-w-screen-xl'
|
? 'max-w-screen-xl'
|
||||||
: 'max-w-3xl',
|
: 'max-w-3xl',
|
||||||
|
!LIST_BLOCKS.includes(node.type) && 'print:break-inside-avoid',
|
||||||
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full',
|
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full',
|
||||||
blockStyle,
|
blockStyle,
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { DocumentBlockCode } from '@gitbook/api';
|
import type { DocumentBlockCode } from '@gitbook/api';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
||||||
import { useInViewportListener } from '@/components/hooks/useInViewportListener';
|
import { useInViewportListener } from '@/components/hooks/useInViewportListener';
|
||||||
import { useScrollListener } from '@/components/hooks/useScrollListener';
|
import { useScrollListener } from '@/components/hooks/useScrollListener';
|
||||||
|
import { Button } from '@/components/primitives';
|
||||||
|
import { t, useLanguage } from '@/intl/client';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { useDebounceCallback } from 'usehooks-ts';
|
import { useDebounceCallback } from 'usehooks-ts';
|
||||||
import type { BlockProps } from '../Block';
|
import type { BlockProps } from '../Block';
|
||||||
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
|
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
|
||||||
@@ -18,6 +21,8 @@ type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> &
|
|||||||
inlineExprVariables: InlineExpressionVariables;
|
inlineExprVariables: InlineExpressionVariables;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a code-block client-side by loading the highlighter asynchronously.
|
* Render a code-block client-side by loading the highlighter asynchronously.
|
||||||
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
|
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
|
||||||
@@ -113,13 +118,82 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
|||||||
setLines(null);
|
setLines(null);
|
||||||
}, [isInViewport, block, inlines, evaluateInlineExpression]);
|
}, [isInViewport, block, inlines, evaluateInlineExpression]);
|
||||||
|
|
||||||
return (
|
const expandable = block.data.expandable;
|
||||||
|
|
||||||
|
const numberOfLinesOfCode = lines?.length ?? plainLines.length;
|
||||||
|
const collapsedLineCount =
|
||||||
|
block.data.collapsedLineCount || CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT;
|
||||||
|
const isExpandable = Boolean(expandable && numberOfLinesOfCode > collapsedLineCount);
|
||||||
|
|
||||||
|
const codeBlockBodyId = useId();
|
||||||
|
|
||||||
|
const renderer = (
|
||||||
<CodeBlockRenderer
|
<CodeBlockRenderer
|
||||||
ref={blockRef}
|
ref={blockRef}
|
||||||
aria-busy={highlighting}
|
aria-busy={highlighting}
|
||||||
block={block}
|
block={block}
|
||||||
style={style}
|
style={style}
|
||||||
lines={lines ?? plainLines}
|
lines={lines ?? plainLines}
|
||||||
|
id={codeBlockBodyId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return isExpandable ? (
|
||||||
|
<CodeBlockExpandable
|
||||||
|
lines={lines ?? plainLines}
|
||||||
|
controls={codeBlockBodyId}
|
||||||
|
collapsedLineCount={collapsedLineCount}
|
||||||
|
>
|
||||||
|
{renderer}
|
||||||
|
</CodeBlockExpandable>
|
||||||
|
) : (
|
||||||
|
renderer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeBlockExpandable(props: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
lines: HighlightLine[];
|
||||||
|
collapsedLineCount: number;
|
||||||
|
controls?: string;
|
||||||
|
}) {
|
||||||
|
const { children, controls, lines = [], collapsedLineCount } = props;
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const language = useLanguage();
|
||||||
|
return (
|
||||||
|
<div className="group/codeblock-expandable relative">
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
isExpanded
|
||||||
|
? '[&_pre]:after:opacity-0'
|
||||||
|
: '[&_pre]:h-[calc(2rem+var(--line-count)*var(--line-height))] [&_pre]:overflow-y-hidden [&_pre]:after:opacity-100',
|
||||||
|
'[&_pre]:after:pointer-events-none [&_pre]:after:absolute [&_pre]:after:inset-0 [&_pre]:after:z-1 [&_pre]:after:bg-gradient-to-t [&_pre]:after:from-0% [&_pre]:after:from-tint-2 [&_pre]:after:to-70% [&_pre]:after:to-transparent [&_pre]:after:content-[""]'
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--line-count': collapsedLineCount,
|
||||||
|
'--line-height': '1.25rem',
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute bottom-0 flex w-full justify-center">
|
||||||
|
<Button
|
||||||
|
icon={isExpanded ? 'chevron-up' : 'chevron-down'}
|
||||||
|
size="xsmall"
|
||||||
|
variant="blank"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="pointer-events-auto z-1 my-2 text-primary text-sm opacity-0 focus:opacity-11 group-hover/codeblock-expandable:opacity-11"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-controls={controls}
|
||||||
|
>
|
||||||
|
{isExpanded
|
||||||
|
? t(language, 'code_block_expanded')
|
||||||
|
: t(language, 'code_block_collapsed', lines.length)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
|
|||||||
return { inline, body };
|
return { inline, body };
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isEstimatedOffscreen && !hasInlineExpression) {
|
if (!isEstimatedOffscreen && !hasInlineExpression && !block.data.expandable) {
|
||||||
// In v2, we render the code block server-side
|
// In v2, we render the code block server-side
|
||||||
const lines = await highlight(block, richInlines);
|
const lines = await highlight(block, richInlines);
|
||||||
return <CodeBlockRenderer block={block} style={style} lines={lines} />;
|
return <CodeBlockRenderer block={block} style={style} lines={lines} />;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { HighlightLine, HighlightToken } from './highlight';
|
|||||||
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||||
lines: HighlightLine[];
|
lines: HighlightLine[];
|
||||||
'aria-busy'?: boolean;
|
'aria-busy'?: boolean;
|
||||||
|
id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,11 +24,12 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
|||||||
) {
|
) {
|
||||||
const { block, style, lines, 'aria-busy': ariaBusy } = props;
|
const { block, style, lines, 'aria-busy': ariaBusy } = props;
|
||||||
|
|
||||||
const id = useId();
|
|
||||||
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
|
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
|
||||||
const withWrap = block.data.overflow === 'wrap';
|
const withWrap = block.data.overflow === 'wrap';
|
||||||
const title = block.data.title;
|
const title = block.data.title;
|
||||||
|
|
||||||
|
const id = useId();
|
||||||
|
const codeId = props.id || id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -42,7 +44,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<CopyCodeButton
|
<CopyCodeButton
|
||||||
codeId={id}
|
codeId={codeId}
|
||||||
style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11"
|
style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11"
|
||||||
/>
|
/>
|
||||||
<pre
|
<pre
|
||||||
@@ -53,7 +55,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<code
|
<code
|
||||||
id={id}
|
id={codeId}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'inline-grid min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
|
'inline-grid min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
|
||||||
withWrap && 'whitespace-pre-wrap'
|
withWrap && 'whitespace-pre-wrap'
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export async function Drawing(props: BlockProps<DocumentBlockDrawing>) {
|
|||||||
alt="Drawing"
|
alt="Drawing"
|
||||||
sizes={imageBlockSizes}
|
sizes={imageBlockSizes}
|
||||||
zoom
|
zoom
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</Caption>
|
</Caption>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
|
|||||||
sources={{ light: { src: embed.icon } }}
|
sources={{ light: { src: embed.icon } }}
|
||||||
sizes={[{ width: 20 }]}
|
sizes={[{ width: 20 }]}
|
||||||
resize={context.contentContext.imageResizer}
|
resize={context.contentContext.imageResizer}
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,29 +17,34 @@ export function Details(props: {
|
|||||||
}) {
|
}) {
|
||||||
const { children, id, className } = props;
|
const { children, id, className } = props;
|
||||||
|
|
||||||
const detailsRef = React.useRef<HTMLDetailsElement>(null);
|
const ref = React.useRef<HTMLDetailsElement>(null);
|
||||||
|
|
||||||
const [openFromHash, setOpenFromHash] = React.useState(false);
|
const [openFromHash, setOpenFromHash] = React.useState(false);
|
||||||
|
|
||||||
const hash = useHash();
|
const hash = useHash();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the details element if the url hash refers to the id of the details element
|
* Open the details element if the url hash refers to the id of the details element
|
||||||
* or the id of some element contained within the details element.
|
* or the id of some element contained within the details element.
|
||||||
*/
|
*/
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!hash || !detailsRef.current) {
|
if (!hash || !ref.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hash === id) {
|
if (hash === id) {
|
||||||
setOpenFromHash(true);
|
setOpenFromHash(true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeElement = document.getElementById(hash);
|
const activeElement = document.getElementById(hash);
|
||||||
setOpenFromHash(Boolean(activeElement && detailsRef.current?.contains(activeElement)));
|
const isOpen = Boolean(activeElement && ref.current.contains(activeElement));
|
||||||
|
setOpenFromHash(isOpen);
|
||||||
}, [hash, id]);
|
}, [hash, id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details
|
<details
|
||||||
ref={detailsRef}
|
ref={ref}
|
||||||
id={id}
|
id={id}
|
||||||
open={props.open || openFromHash}
|
open={props.open || openFromHash}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
|
|||||||
className={tcls(
|
className={tcls(
|
||||||
'inline-block',
|
'inline-block',
|
||||||
'size-3',
|
'size-3',
|
||||||
'mr-2',
|
'mr-3',
|
||||||
'mb-1',
|
'mb-1',
|
||||||
'transition-transform',
|
'transition-transform',
|
||||||
'shrink-0',
|
'shrink-0',
|
||||||
@@ -96,7 +96,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
|
|||||||
document={document}
|
document={document}
|
||||||
ancestorBlocks={[...ancestorBlocks, block]}
|
ancestorBlocks={[...ancestorBlocks, block]}
|
||||||
context={context}
|
context={context}
|
||||||
style={['px-10', 'pb-5', 'space-y-4']}
|
style="space-y-4 px-10 pb-5"
|
||||||
/>
|
/>
|
||||||
</Details>
|
</Details>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { type DocumentBlockFile, SiteInsightsLinkPosition } from '@gitbook/api';
|
import { type DocumentBlockFile, SiteInsightsLinkPosition } from '@gitbook/api';
|
||||||
|
|
||||||
|
import { t } from '@/intl/translate';
|
||||||
import { getSimplifiedContentType } from '@/lib/files';
|
import { getSimplifiedContentType } from '@/lib/files';
|
||||||
import { resolveContentRef } from '@/lib/references';
|
import { resolveContentRef } from '@/lib/references';
|
||||||
import { tcls } from '@/lib/tailwind';
|
|
||||||
|
|
||||||
import { Link } from '../primitives';
|
import { getSpaceLanguage } from '@/intl/server';
|
||||||
|
import { Button, Link } from '../primitives';
|
||||||
|
import { DownloadButton } from '../primitives/DownloadButton';
|
||||||
import type { BlockProps } from './Block';
|
import type { BlockProps } from './Block';
|
||||||
import { Caption } from './Caption';
|
import { Caption } from './Caption';
|
||||||
import { FileIcon } from './FileIcon';
|
import { FileIcon } from './FileIcon';
|
||||||
@@ -12,67 +14,70 @@ import { FileIcon } from './FileIcon';
|
|||||||
export async function File(props: BlockProps<DocumentBlockFile>) {
|
export async function File(props: BlockProps<DocumentBlockFile>) {
|
||||||
const { block, context } = props;
|
const { block, context } = props;
|
||||||
|
|
||||||
const contentRef = context.contentContext
|
if (!context.contentContext) {
|
||||||
? await resolveContentRef(block.data.ref, context.contentContext)
|
return null;
|
||||||
: null;
|
}
|
||||||
|
|
||||||
|
const contentRef = await resolveContentRef(block.data.ref, context.contentContext);
|
||||||
const file = contentRef?.file;
|
const file = contentRef?.file;
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const language = getSpaceLanguage(context.contentContext);
|
||||||
const contentType = getSimplifiedContentType(file.contentType);
|
const contentType = getSimplifiedContentType(file.contentType);
|
||||||
|
const insights = {
|
||||||
|
type: 'link_click' as const,
|
||||||
|
link: {
|
||||||
|
target: block.data.ref,
|
||||||
|
position: SiteInsightsLinkPosition.Content,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Caption {...props} withBorder>
|
<Caption {...props} withBorder>
|
||||||
<Link
|
<div className="flex flex-wrap items-center gap-5 px-5 py-3">
|
||||||
href={file.downloadURL}
|
<div className="flex min-w-14 flex-col items-center gap-1 border-tint-subtle border-r pr-5">
|
||||||
download={file.name}
|
<FileIcon contentType={contentType} className="size-5 text-primary" />
|
||||||
insights={{
|
<div className="text-hint text-xs">{getHumanFileSize(file.size)}</div>
|
||||||
type: 'link_click',
|
</div>
|
||||||
link: {
|
<div className="min-w-24 flex-1">
|
||||||
target: block.data.ref,
|
<div className="text-base">
|
||||||
position: SiteInsightsLinkPosition.Content,
|
<Link
|
||||||
},
|
href={file.downloadURL}
|
||||||
}}
|
target="_blank"
|
||||||
className={tcls('group/file', 'flex', 'flex-row', 'items-center', 'px-5', 'py-3')}
|
insights={insights}
|
||||||
>
|
className="hover:underline"
|
||||||
<div
|
>
|
||||||
className={tcls(
|
{file.name}
|
||||||
'min-w-14',
|
</Link>
|
||||||
'mr-5',
|
|
||||||
'pr-5',
|
|
||||||
'flex',
|
|
||||||
'flex-col',
|
|
||||||
'items-center',
|
|
||||||
'gap-1',
|
|
||||||
'border-r',
|
|
||||||
'border-tint-subtle'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<FileIcon
|
|
||||||
contentType={contentType}
|
|
||||||
className={tcls('size-5', 'text-primary')}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="text-sm opacity-9 dark:opacity-8">{contentType}</div>
|
||||||
className={tcls(
|
</div>
|
||||||
'text-xs',
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
'text-tint',
|
<DownloadButton
|
||||||
'group-hover/file:text-tint-strong'
|
icon="download"
|
||||||
)}
|
size="xsmall"
|
||||||
|
variant="secondary"
|
||||||
|
downloadUrl={file.downloadURL}
|
||||||
|
filename={file.name}
|
||||||
|
insights={insights}
|
||||||
>
|
>
|
||||||
{getHumanFileSize(file.size)}
|
{t(language, 'download')}
|
||||||
</div>
|
</DownloadButton>
|
||||||
|
<Button
|
||||||
|
icon="arrow-up-right-from-square"
|
||||||
|
size="xsmall"
|
||||||
|
variant="secondary"
|
||||||
|
href={file.downloadURL}
|
||||||
|
target="_blank"
|
||||||
|
insights={insights}
|
||||||
|
>
|
||||||
|
{t(language, 'open')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<div className={tcls('text-base')}>{file.name}</div>
|
|
||||||
<div className={tcls('text-sm', 'opacity-9', 'dark:opacity-8')}>
|
|
||||||
{contentType}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</Caption>
|
</Caption>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function FileIcon(props: { contentType: SimplifiedFileType | null; classN
|
|||||||
const { contentType, className } = props;
|
const { contentType, className } = props;
|
||||||
|
|
||||||
switch (contentType) {
|
switch (contentType) {
|
||||||
case 'pdf':
|
case 'PDF':
|
||||||
return <Icon icon="file-pdf" className={className} />;
|
return <Icon icon="file-pdf" className={className} />;
|
||||||
case 'image':
|
case 'image':
|
||||||
return <Icon icon="file-image" className={className} />;
|
return <Icon icon="file-image" className={className} />;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||||
import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api';
|
import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api';
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
|
import { Link } from '../primitives';
|
||||||
import { getBlockTextStyle } from './spacing';
|
import { getBlockTextStyle } from './spacing';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,6 +29,8 @@ export function HashLinkButton(props: {
|
|||||||
'h-[1em]',
|
'h-[1em]',
|
||||||
'border-0',
|
'border-0',
|
||||||
'opacity-0',
|
'opacity-0',
|
||||||
|
'site-background',
|
||||||
|
'rounded',
|
||||||
'group-hover/hash:opacity-[0]',
|
'group-hover/hash:opacity-[0]',
|
||||||
'group-focus/hash:opacity-[0]',
|
'group-focus/hash:opacity-[0]',
|
||||||
'md:group-hover/hash:opacity-[1]',
|
'md:group-hover/hash:opacity-[1]',
|
||||||
@@ -35,10 +38,10 @@ export function HashLinkButton(props: {
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<a
|
<Link
|
||||||
href={`#${id}`}
|
href={`#${id}`}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)}
|
className={tcls('inline-flex h-full items-start', textStyle.lineHeight)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon="hashtag"
|
icon="hashtag"
|
||||||
@@ -52,7 +55,7 @@ export function HashLinkButton(props: {
|
|||||||
iconClassName
|
iconClassName
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,34 +19,42 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
|
|||||||
className={tcls(
|
className={tcls(
|
||||||
style,
|
style,
|
||||||
'flex',
|
'flex',
|
||||||
'flex-row',
|
'w-full',
|
||||||
'gap-3',
|
|
||||||
align === 'center' && 'justify-center',
|
align === 'center' && 'justify-center',
|
||||||
align === 'right' && 'justify-end',
|
align === 'right' && 'justify-end',
|
||||||
align === 'left' && 'justify-start',
|
align === 'left' && 'justify-start'
|
||||||
hasMultipleImages && ['grid', 'grid-flow-col'],
|
|
||||||
withFrame && [
|
|
||||||
'rounded-2xl',
|
|
||||||
'border',
|
|
||||||
'border-[rgb(234,235,238)]',
|
|
||||||
'dark:border-[rgb(45,50,58)]',
|
|
||||||
'relative',
|
|
||||||
'overflow-hidden',
|
|
||||||
]
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{block.nodes.map((node: any, _i: number) => (
|
<div
|
||||||
<ImageBlock
|
className={tcls(
|
||||||
key={node.key}
|
'flex',
|
||||||
block={node}
|
'flex-row',
|
||||||
document={document}
|
'gap-3',
|
||||||
style={[]}
|
hasMultipleImages && ['grid', 'grid-flow-col'],
|
||||||
siblings={block.nodes.length}
|
withFrame && [
|
||||||
context={context}
|
'rounded-2xl',
|
||||||
isEstimatedOffscreen={isEstimatedOffscreen}
|
'border',
|
||||||
withFrame={withFrame}
|
'border-[rgb(234,235,238)]',
|
||||||
/>
|
'dark:border-[rgb(45,50,58)]',
|
||||||
))}
|
'relative',
|
||||||
|
'overflow-hidden',
|
||||||
|
'w-fit',
|
||||||
|
]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{block.nodes.map((node: any, _i: number) => (
|
||||||
|
<ImageBlock
|
||||||
|
key={node.key}
|
||||||
|
block={node}
|
||||||
|
document={document}
|
||||||
|
style={[]}
|
||||||
|
siblings={block.nodes.length}
|
||||||
|
context={context}
|
||||||
|
isEstimatedOffscreen={isEstimatedOffscreen}
|
||||||
|
withFrame={withFrame}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -135,8 +143,7 @@ async function ImageBlock(props: {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}}
|
}}
|
||||||
priority={isEstimatedOffscreen ? 'lazy' : 'high'}
|
loading={isEstimatedOffscreen ? 'lazy' : 'eager'}
|
||||||
preload
|
|
||||||
zoom
|
zoom
|
||||||
inlineStyle={{
|
inlineStyle={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { DocumentInlineIcon, DocumentMarkColor } from '@gitbook/api';
|
import type { DocumentInlineIcon } from '@gitbook/api';
|
||||||
|
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { Icon, type IconName } from '@gitbook/icons';
|
import { Icon, type IconName } from '@gitbook/icons';
|
||||||
@@ -7,16 +7,11 @@ import { textColorToStyle } from './utils/colors';
|
|||||||
|
|
||||||
export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) {
|
export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) {
|
||||||
const { inline } = props;
|
const { inline } = props;
|
||||||
const icon = inline.data.icon as IconName;
|
const { color, icon } = inline.data;
|
||||||
// @ts-expect-error remove this comment once API is updated
|
|
||||||
const color = inline.data.color
|
|
||||||
? // @ts-expect-error remove "as DocumentMarkColor['data']['text']" once API is updated
|
|
||||||
(inline.data.color as DocumentMarkColor['data']['text'])
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Icon
|
<Icon
|
||||||
icon={icon}
|
icon={icon as IconName}
|
||||||
className={tcls('inline size-[1em]', color ? textColorToStyle[color] : null)}
|
className={tcls('inline size-[1em]', color ? textColorToStyle[color] : null)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}}
|
}}
|
||||||
priority="lazy"
|
loading="lazy"
|
||||||
preload
|
|
||||||
style={[size === 'line' ? ['max-h-lh', 'h-lh', 'w-auto'] : null]}
|
style={[size === 'line' ? ['max-h-lh', 'h-lh', 'w-auto'] : null]}
|
||||||
inline
|
inline
|
||||||
zoom={!isInLink}
|
zoom={!isInLink}
|
||||||
|
|||||||
@@ -1,37 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import dynamic from 'next/dynamic';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import React from 'react';
|
import { Icon } from '@gitbook/icons';
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
|
||||||
|
|
||||||
const LoadingValueContext = React.createContext<React.ReactNode>(null);
|
|
||||||
|
|
||||||
// To avoid polluting the RSC payload with the tooltip implementation,
|
|
||||||
// we lazily load it on the client side. This way, the tooltip is only loaded
|
|
||||||
// when the user interacts with the link, and it doesn't block the initial render.
|
|
||||||
|
|
||||||
const InlineLinkTooltipImpl = dynamic(
|
|
||||||
() => import('./InlineLinkTooltipImpl').then((mod) => mod.InlineLinkTooltipImpl),
|
|
||||||
{
|
|
||||||
// Disable server-side rendering for this component, it's only
|
|
||||||
// visible on user interaction.
|
|
||||||
ssr: false,
|
|
||||||
loading: () => {
|
|
||||||
// The fallback should be the children (the content of the link),
|
|
||||||
// but as next/dynamic is aiming for feature parity with React.lazy,
|
|
||||||
// it doesn't support passing children to the loading component.
|
|
||||||
// https://github.com/vercel/next.js/issues/7906
|
|
||||||
const children = React.useContext(LoadingValueContext);
|
|
||||||
return <>{children}</>;
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tooltip for inline links. It's lazily loaded to avoid blocking the initial render
|
|
||||||
* and polluting the RSC payload.
|
|
||||||
*
|
|
||||||
* The link text and href have already been rendered on the server for good SEO,
|
|
||||||
* so we can be as lazy as possible with the tooltip.
|
|
||||||
*/
|
|
||||||
export function InlineLinkTooltip(props: {
|
export function InlineLinkTooltip(props: {
|
||||||
isSamePage: boolean;
|
isSamePage: boolean;
|
||||||
isExternal: boolean;
|
isExternal: boolean;
|
||||||
@@ -45,28 +17,79 @@ export function InlineLinkTooltip(props: {
|
|||||||
openInNewTabLabel: string;
|
openInNewTabLabel: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { children, ...rest } = props;
|
const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
|
||||||
const [shouldLoad, setShouldLoad] = React.useState(false);
|
|
||||||
|
|
||||||
// Once the browser is idle, we set shouldLoad to true.
|
return (
|
||||||
// NOTE: to be slightly more performant, we could load when a link is hovered.
|
<HoverCardRoot>
|
||||||
// But I found this was too much of a delay for the tooltip to appear.
|
<HoverCardTrigger>{children}</HoverCardTrigger>
|
||||||
// Loading on idle is a good compromise, as it allows the initial render to be fast,
|
<HoverCard className="p-4">
|
||||||
// while still loading the tooltip in the background and not polluting the RSC payload.
|
<div className="flex items-start gap-4">
|
||||||
React.useEffect(() => {
|
<div className="flex flex-col">
|
||||||
if ('requestIdleCallback' in window) {
|
{breadcrumbs && breadcrumbs.length > 0 ? (
|
||||||
(window as globalThis.Window).requestIdleCallback(() => setShouldLoad(true));
|
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
|
||||||
} else {
|
{breadcrumbs.map((crumb, index) => {
|
||||||
// fallback for old browsers
|
const Tag = crumb.href ? StyledLink : 'div';
|
||||||
setTimeout(() => setShouldLoad(true), 2000);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return shouldLoad ? (
|
return (
|
||||||
<LoadingValueContext.Provider value={children}>
|
<Fragment key={crumb.label}>
|
||||||
<InlineLinkTooltipImpl {...rest}>{children}</InlineLinkTooltipImpl>
|
{index !== 0 ? (
|
||||||
</LoadingValueContext.Provider>
|
<Icon
|
||||||
) : (
|
icon="chevron-right"
|
||||||
children
|
className="size-3 text-tint-subtle"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Tag
|
||||||
|
className={tcls(
|
||||||
|
'flex gap-1',
|
||||||
|
crumb.href &&
|
||||||
|
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
|
||||||
|
)}
|
||||||
|
href={crumb.href ?? '#'}
|
||||||
|
>
|
||||||
|
{crumb.icon ? (
|
||||||
|
<span className="mt-0.5 text-tint-subtle empty:hidden">
|
||||||
|
{crumb.icon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{crumb.label}
|
||||||
|
</Tag>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'flex gap-2 leading-snug',
|
||||||
|
isExternal && 'wrap-anywhere text-sm'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{target.icon ? (
|
||||||
|
<div className="mt-1 text-tint-subtle empty:hidden">
|
||||||
|
{target.icon}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<h5 className="font-semibold">{target.text}</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isSamePage && target.href ? (
|
||||||
|
<Button
|
||||||
|
className={tcls(
|
||||||
|
'-mx-2 -my-2 ml-auto',
|
||||||
|
breadcrumbs?.length === 0 ? 'place-self-center' : null
|
||||||
|
)}
|
||||||
|
variant="blank"
|
||||||
|
href={target.href}
|
||||||
|
target="_blank"
|
||||||
|
label={openInNewTabLabel}
|
||||||
|
size="small"
|
||||||
|
icon="arrow-up-right-from-square"
|
||||||
|
iconOnly={true}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{target.subText ? <p className="mt-1 text-sm text-tint">{target.subText}</p> : null}
|
||||||
|
</HoverCard>
|
||||||
|
</HoverCardRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import { tcls } from '@/lib/tailwind';
|
|
||||||
import { Icon } from '@gitbook/icons';
|
|
||||||
import { Fragment } from 'react';
|
|
||||||
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
|
|
||||||
|
|
||||||
export function InlineLinkTooltipImpl(props: {
|
|
||||||
isSamePage: boolean;
|
|
||||||
isExternal: boolean;
|
|
||||||
breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>;
|
|
||||||
target: {
|
|
||||||
href: string;
|
|
||||||
text: string;
|
|
||||||
subText?: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
};
|
|
||||||
openInNewTabLabel: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HoverCardRoot>
|
|
||||||
<HoverCardTrigger>{children}</HoverCardTrigger>
|
|
||||||
<HoverCard className="p-4">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{breadcrumbs && breadcrumbs.length > 0 ? (
|
|
||||||
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
|
|
||||||
{breadcrumbs.map((crumb, index) => {
|
|
||||||
const Tag = crumb.href ? StyledLink : 'div';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment key={crumb.label}>
|
|
||||||
{index !== 0 ? (
|
|
||||||
<Icon
|
|
||||||
icon="chevron-right"
|
|
||||||
className="size-3 text-tint-subtle"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Tag
|
|
||||||
className={tcls(
|
|
||||||
'flex gap-1',
|
|
||||||
crumb.href &&
|
|
||||||
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
|
|
||||||
)}
|
|
||||||
href={crumb.href ?? '#'}
|
|
||||||
>
|
|
||||||
{crumb.icon ? (
|
|
||||||
<span className="mt-0.5 text-tint-subtle empty:hidden">
|
|
||||||
{crumb.icon}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{crumb.label}
|
|
||||||
</Tag>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div
|
|
||||||
className={tcls(
|
|
||||||
'flex gap-2 leading-snug',
|
|
||||||
isExternal && 'wrap-anywhere text-sm'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{target.icon ? (
|
|
||||||
<div className="mt-1 text-tint-subtle empty:hidden">
|
|
||||||
{target.icon}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<h5 className="font-semibold">{target.text}</h5>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!isSamePage && target.href ? (
|
|
||||||
<Button
|
|
||||||
className={tcls(
|
|
||||||
'-mx-2 -my-2 ml-auto',
|
|
||||||
breadcrumbs?.length === 0 ? 'place-self-center' : null
|
|
||||||
)}
|
|
||||||
variant="blank"
|
|
||||||
href={target.href}
|
|
||||||
target="_blank"
|
|
||||||
label={openInNewTabLabel}
|
|
||||||
size="small"
|
|
||||||
icon="arrow-up-right-from-square"
|
|
||||||
iconOnly={true}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{target.subText ? <p className="mt-1 text-sm text-tint">{target.subText}</p> : null}
|
|
||||||
</HoverCard>
|
|
||||||
</HoverCardRoot>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -39,6 +39,8 @@ export function getOpenAPIContext(args: {
|
|||||||
chevronDown: <Icon icon="chevron-down" />,
|
chevronDown: <Icon icon="chevron-down" />,
|
||||||
chevronRight: <Icon icon="chevron-right" />,
|
chevronRight: <Icon icon="chevron-right" />,
|
||||||
plus: <Icon icon="plus" />,
|
plus: <Icon icon="plus" />,
|
||||||
|
copy: <Icon icon="copy" />,
|
||||||
|
check: <Icon icon="check" />,
|
||||||
},
|
},
|
||||||
renderCodeBlock: (codeProps) => <PlainCodeBlock {...codeProps} />,
|
renderCodeBlock: (codeProps) => <PlainCodeBlock {...codeProps} />,
|
||||||
renderDocument: (documentProps) => (
|
renderDocument: (documentProps) => (
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
.openapi-deprecated,
|
.openapi-deprecated,
|
||||||
.openapi-stability {
|
.openapi-stability {
|
||||||
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
|
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-sm text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-stability-alpha {
|
.openapi-stability-alpha {
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-markdown code {
|
.openapi-markdown code {
|
||||||
@apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
|
@apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-markdown pre code {
|
.openapi-markdown pre code {
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
/* Method Tags */
|
/* Method Tags */
|
||||||
.openapi-method,
|
.openapi-method,
|
||||||
.openapi-statuscode {
|
.openapi-statuscode {
|
||||||
@apply rounded uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
|
@apply rounded straight-corners:rounded-none circular-corners:rounded-md uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-method-get,
|
.openapi-method-get,
|
||||||
@@ -270,11 +270,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-enum-value:first-child {
|
.openapi-schema-enum-value:first-child {
|
||||||
@apply rounded-l ml-0;
|
@apply rounded-l straight-corners:rounded-none circular-corners:rounded-l-md ml-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-enum-value:last-child {
|
.openapi-schema-enum-value:last-child {
|
||||||
@apply rounded-r;
|
@apply rounded-r straight-corners:rounded-none circular-corners:rounded-r-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Schema Description */
|
/* Schema Description */
|
||||||
@@ -308,7 +308,7 @@
|
|||||||
.openapi-schema-pattern code,
|
.openapi-schema-pattern code,
|
||||||
.openapi-schema-enum-value code,
|
.openapi-schema-enum-value code,
|
||||||
.openapi-schema-default code {
|
.openapi-schema-default code {
|
||||||
@apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
|
@apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Authentication */
|
/* Authentication */
|
||||||
@@ -325,6 +325,10 @@
|
|||||||
@apply prose *:!prose-sm *:text-tint;
|
@apply prose *:!prose-sm *:text-tint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.openapi-securities-oauth-content {
|
||||||
|
@apply flex flex-col gap-1 mt-1;
|
||||||
|
}
|
||||||
|
|
||||||
.openapi-securities-oauth-content.openapi-markdown code {
|
.openapi-securities-oauth-content.openapi-markdown code {
|
||||||
@apply text-xs;
|
@apply text-xs;
|
||||||
}
|
}
|
||||||
@@ -334,7 +338,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-securities-url {
|
.openapi-securities-url {
|
||||||
@apply ml-0.5 px-0.5 rounded hover:bg-tint transition-colors;
|
@apply ml-0.5 px-0.5 rounded straight-corners:rounded-none circular-corners:rounded-md hover:bg-tint dark:hover:bg-tint-hover transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-securities-body {
|
.openapi-securities-body {
|
||||||
@@ -454,6 +458,10 @@
|
|||||||
@apply flex items-center font-mono text-[0.813rem] gap-1 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
|
@apply flex items-center font-mono text-[0.813rem] gap-1 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.openapi-codesample-header-content .openapi-path-title {
|
||||||
|
@apply block;
|
||||||
|
}
|
||||||
|
|
||||||
.openapi-codesample-header-content .openapi-path .openapi-path-variable {
|
.openapi-codesample-header-content .openapi-path .openapi-path-variable {
|
||||||
@apply text-[0.813rem];
|
@apply text-[0.813rem];
|
||||||
}
|
}
|
||||||
@@ -474,21 +482,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-path-variable {
|
.openapi-path-variable {
|
||||||
@apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-none before:content-none! after:!content-none;
|
@apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-none before:content-none! after:!content-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-path-server {
|
.openapi-path-server {
|
||||||
@apply text-tint hidden md:inline;
|
@apply text-tint inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-path .openapi-method {
|
.openapi-summary .openapi-path .openapi-method {
|
||||||
@apply m-0 mt-0.5 items-center flex px-1;
|
@apply m-0 items-center flex px-2 py-1 h-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-path-title {
|
.openapi-path-title {
|
||||||
@apply flex-1 relative font-normal text-left font-mono text-tint-strong/10;
|
@apply flex-1 relative font-normal items-center gap-y-1 flex flex-wrap text-left overflow-x-auto font-mono text-tint-strong/10;
|
||||||
@apply py-0.5 px-1 rounded hover:bg-tint transition-colors;
|
|
||||||
@apply whitespace-nowrap md:whitespace-normal;
|
@apply whitespace-nowrap md:whitespace-normal;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-path-title-row{
|
||||||
|
@apply flex flex-row items-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-path-title[data-deprecated="true"] {
|
.openapi-path-title[data-deprecated="true"] {
|
||||||
@@ -592,18 +605,30 @@ body:has(.openapi-select-popover) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-select > button {
|
.openapi-select > button {
|
||||||
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 text-tint-strong rounded text-xs p-1.5 leading-none border border-tint-subtle bg-tint;
|
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded straight-corners:rounded-none circular-corners:rounded-md leading-none;
|
||||||
@apply hover:bg-tint-hover transition-all;
|
@apply hover:bg-tint dark:hover:bg-tint-hover transition-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-select:not(.openapi-select-unstyled) > button {
|
||||||
|
@apply border border-tint-subtle bg-tint text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-select-unstyled > button {
|
||||||
|
@apply p-1 *:truncate max-w-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-select > button[data-focused="true"] {
|
.openapi-select > button[data-focused="true"] {
|
||||||
@apply outline-primary -outline-offset-1 outline outline-1;
|
@apply outline-primary -outline-offset-1 outline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-select > button > span.react-aria-SelectValue {
|
.openapi-select > button > span.react-aria-SelectValue {
|
||||||
@apply shrink truncate flex items-center;
|
@apply shrink truncate flex items-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.openapi-select > button > .react-aria-SelectValue [slot="description"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.openapi-select > button .openapi-markdown {
|
.openapi-select > button .openapi-markdown {
|
||||||
@apply *:leading-none;
|
@apply *:leading-none;
|
||||||
}
|
}
|
||||||
@@ -613,7 +638,7 @@ body:has(.openapi-select-popover) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-select-popover {
|
.openapi-select-popover {
|
||||||
@apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
|
@apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md straight-corners:rounded-none circular-corners:rounded-xl;
|
||||||
@apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1;
|
@apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,10 +651,18 @@ body:has(.openapi-select-popover) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-select-item {
|
.openapi-select-item {
|
||||||
@apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded !outline-none;
|
@apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded straight-corners:rounded-none circular-corners:rounded-md !outline-none;
|
||||||
@apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
|
@apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.openapi-select-item.openapi-select-item-column {
|
||||||
|
@apply flex flex-col gap-1 justify-start items-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-select-item [slot="description"] {
|
||||||
|
@apply text-xs text-tint-subtle;
|
||||||
|
}
|
||||||
|
|
||||||
.openapi-select button .openapi-markdown,
|
.openapi-select button .openapi-markdown,
|
||||||
.openapi-select-item .openapi-markdown {
|
.openapi-select-item .openapi-markdown {
|
||||||
@apply text-[0.813rem] *:truncate *:!p-0 *:!m-0 [&>*:not(:first-child)]:hidden;
|
@apply text-[0.813rem] *:truncate *:!p-0 *:!m-0 [&>*:not(:first-child)]:hidden;
|
||||||
@@ -714,7 +747,7 @@ body:has(.openapi-select-popover) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-tabs-tab {
|
.openapi-tabs-tab {
|
||||||
@apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded;
|
@apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded straight-corners:rounded-none circular-corners:rounded-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-tabs-tab[aria-selected="true"] {
|
.openapi-tabs-tab[aria-selected="true"] {
|
||||||
@@ -785,12 +818,12 @@ body:has(.openapi-select-popover) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schemas-disclosure > .openapi-disclosure-trigger {
|
.openapi-schemas-disclosure > .openapi-disclosure-trigger {
|
||||||
@apply flex items-center font-mono transition-all font-normal text-tint-strong !text-sm hover:bg-tint-subtle relative flex-1 gap-2.5 p-5 truncate -outline-offset-1;
|
@apply flex items-center font-mono transition-all font-normal text-tint-strong !text-sm hover:bg-tint-subtle dark:hover:bg-tint-hover relative flex-1 gap-2.5 p-5 truncate -outline-offset-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schemas-disclosure > .openapi-disclosure-trigger,
|
.openapi-schemas-disclosure > .openapi-disclosure-trigger,
|
||||||
.openapi-schemas-disclosure .openapi-disclosure-panel {
|
.openapi-schemas-disclosure .openapi-disclosure-panel {
|
||||||
@apply straight-corners:!rounded-none;
|
@apply straight-corners:!rounded-none circular-corners:!rounded-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure-panel {
|
.openapi-disclosure-panel {
|
||||||
@@ -818,7 +851,7 @@ body:has(.openapi-select-popover) {
|
|||||||
.openapi-schema-alternatives .openapi-disclosure,
|
.openapi-schema-alternatives .openapi-disclosure,
|
||||||
.openapi-schemas-disclosure .openapi-schema.openapi-disclosure
|
.openapi-schemas-disclosure .openapi-schema.openapi-disclosure
|
||||||
) {
|
) {
|
||||||
@apply rounded-xl;
|
@apply rounded-xl straight-corners:rounded-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure {
|
.openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure {
|
||||||
@@ -966,3 +999,20 @@ body:has(.openapi-select-popover) {
|
|||||||
.openapi-copy-button[data-disabled="true"] {
|
.openapi-copy-button[data-disabled="true"] {
|
||||||
@apply cursor-default;
|
@apply cursor-default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.openapi-path-copy-button {
|
||||||
|
@apply p-1 flex rounded-md straight-corners:rounded-none;
|
||||||
|
@apply hover:bg-tint dark:hover:bg-tint-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-path-copy-button-icon {
|
||||||
|
@apply size-6 opacity-0 transition-all hidden sm:flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-path:hover .openapi-path-copy-button-icon {
|
||||||
|
@apply opacity-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-path-copy-button-icon svg {
|
||||||
|
@apply text-tint size-4;
|
||||||
|
}
|
||||||
@@ -85,11 +85,13 @@ export async function RecordCard(
|
|||||||
light: {
|
light: {
|
||||||
src: lightCover.href,
|
src: lightCover.href,
|
||||||
size: lightCover.file?.dimensions,
|
size: lightCover.file?.dimensions,
|
||||||
|
alt: light.alt,
|
||||||
},
|
},
|
||||||
dark: darkCover
|
dark: darkCover
|
||||||
? {
|
? {
|
||||||
src: darkCover.href,
|
src: darkCover.href,
|
||||||
size: darkCover.file?.dimensions,
|
size: darkCover.file?.dimensions,
|
||||||
|
alt: dark.alt,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}}
|
}}
|
||||||
@@ -120,8 +122,7 @@ export async function RecordCard(
|
|||||||
: ['h-auto', 'aspect-video'],
|
: ['h-auto', 'aspect-video'],
|
||||||
objectFits
|
objectFits
|
||||||
)}
|
)}
|
||||||
priority={isOffscreen ? 'lazy' : 'high'}
|
loading={isOffscreen ? 'lazy' : 'eager'}
|
||||||
preload
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
priority="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FileIcon
|
<FileIcon
|
||||||
@@ -404,7 +404,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
|||||||
size: image.file?.dimensions,
|
size: image.file?.dimensions,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
priority="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{image.text}
|
{image.text}
|
||||||
</StyledLink>
|
</StyledLink>
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ export function getRecordValue<T extends number | string | boolean | string[] |
|
|||||||
return record.values[definitionId];
|
return record.values[definitionId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecordCardCover = {
|
||||||
|
contentRef: ContentRefFile | ContentRefURL | null;
|
||||||
|
objectFit?: CardsImageObjectFit;
|
||||||
|
alt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the covers for a record card.
|
* Get the covers for a record card.
|
||||||
* Returns both the light and dark covers with their content refs and optional object fit.
|
* Returns both the light and dark covers with their content refs and optional object fit.
|
||||||
@@ -31,10 +37,7 @@ export function getRecordCardCovers(
|
|||||||
record: DocumentTableRecord,
|
record: DocumentTableRecord,
|
||||||
view: DocumentTableViewCards
|
view: DocumentTableViewCards
|
||||||
): {
|
): {
|
||||||
[key in 'light' | 'dark']: {
|
[key in 'light' | 'dark']: RecordCardCover;
|
||||||
contentRef: ContentRefFile | ContentRefURL | null;
|
|
||||||
objectFit?: CardsImageObjectFit;
|
|
||||||
};
|
|
||||||
} {
|
} {
|
||||||
const lightValue = view.coverDefinition
|
const lightValue = view.coverDefinition
|
||||||
? (getRecordValue(record, view.coverDefinition) as DocumentTableImageRecord | string[])
|
? (getRecordValue(record, view.coverDefinition) as DocumentTableImageRecord | string[])
|
||||||
@@ -53,10 +56,9 @@ export function getRecordCardCovers(
|
|||||||
/**
|
/**
|
||||||
* Process a cover value and return the content ref and object fit.
|
* Process a cover value and return the content ref and object fit.
|
||||||
*/
|
*/
|
||||||
function processCoverValue(value: DocumentTableImageRecord | string[] | null | undefined): {
|
function processCoverValue(
|
||||||
contentRef: ContentRefFile | ContentRefURL | null;
|
value: DocumentTableImageRecord | string[] | null | undefined
|
||||||
objectFit?: CardsImageObjectFit;
|
): RecordCardCover {
|
||||||
} {
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return { contentRef: null };
|
return { contentRef: null };
|
||||||
}
|
}
|
||||||
@@ -78,6 +80,7 @@ function processCoverValue(value: DocumentTableImageRecord | string[] | null | u
|
|||||||
return {
|
return {
|
||||||
contentRef: imageValue.ref,
|
contentRef: imageValue.ref,
|
||||||
objectFit: imageValue.objectFit,
|
objectFit: imageValue.objectFit,
|
||||||
|
alt: imageValue.alt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useCallback, useMemo } from 'react';
|
import React, {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ComponentPropsWithRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import { useHash, useIsMounted } from '@/components/hooks';
|
import { useHash, useListOverflow } from '@/components/hooks';
|
||||||
|
import { DropdownMenu, DropdownMenuItem } from '@/components/primitives';
|
||||||
|
import { useLanguage } from '@/intl/client';
|
||||||
|
import { tString } from '@/intl/translate';
|
||||||
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
|
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
|
||||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import type { DocumentBlockTabs } from '@gitbook/api';
|
import { Icon } from '@gitbook/icons';
|
||||||
import { HashLinkButton, hashLinkButtonWrapperStyles } from '../HashLinkButton';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
interface TabsState {
|
interface TabsState {
|
||||||
activeIds: {
|
activeIds: {
|
||||||
@@ -46,16 +56,9 @@ const TITLES_MAX = 5;
|
|||||||
export interface TabsItem {
|
export interface TabsItem {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
body: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SelectorMapper<Type> = {
|
|
||||||
[Property in keyof Type]: Type[Property];
|
|
||||||
};
|
|
||||||
type TabsInput = {
|
|
||||||
id: string;
|
|
||||||
tabs: SelectorMapper<TabsItem>[];
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TabsState {
|
interface TabsState {
|
||||||
activeIds: {
|
activeIds: {
|
||||||
[tabsBlockId: string]: string;
|
[tabsBlockId: string]: string;
|
||||||
@@ -66,16 +69,16 @@ interface TabsState {
|
|||||||
/**
|
/**
|
||||||
* Client side component for the tabs, taking care of interactions.
|
* Client side component for the tabs, taking care of interactions.
|
||||||
*/
|
*/
|
||||||
export function DynamicTabs(
|
export function DynamicTabs(props: {
|
||||||
props: TabsInput & {
|
id: string;
|
||||||
tabsBody: React.ReactNode[];
|
tabs: TabsItem[];
|
||||||
style: ClassValue;
|
className?: string;
|
||||||
block: DocumentBlockTabs;
|
}) {
|
||||||
}
|
const { id, tabs, className } = props;
|
||||||
) {
|
const router = useRouter();
|
||||||
const { id, block, tabs, tabsBody, style } = props;
|
|
||||||
|
|
||||||
const hash = useHash();
|
const hash = useHash();
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
const [tabsState, setTabsState] = useTabsState();
|
const [tabsState, setTabsState] = useTabsState();
|
||||||
const activeState = useMemo(() => {
|
const activeState = useMemo(() => {
|
||||||
const input = { id, tabs };
|
const input = { id, tabs };
|
||||||
@@ -84,41 +87,98 @@ export function DynamicTabs(
|
|||||||
);
|
);
|
||||||
}, [id, tabs, tabsState]);
|
}, [id, tabs, tabsState]);
|
||||||
|
|
||||||
|
// Track if the tab has been touched by the user.
|
||||||
|
const touchedRef = useRef(false);
|
||||||
|
|
||||||
// To avoid issue with hydration, we only use the state from localStorage
|
// To avoid issue with hydration, we only use the state from localStorage
|
||||||
// once the component has been mounted.
|
// once the component has been initialized (=mounted).
|
||||||
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
|
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
|
||||||
// and get stuck into an inconsistent state.
|
// and get stuck into an inconsistent state.
|
||||||
const mounted = useIsMounted();
|
const active = initialized ? activeState : tabs[0];
|
||||||
const active = mounted ? activeState : tabs[0];
|
|
||||||
|
|
||||||
/**
|
// When clicking to select a tab, we:
|
||||||
* When clicking to select a tab, we:
|
// - update the URL hash
|
||||||
* - mark this specific ID as selected
|
// - mark this specific ID as selected
|
||||||
* - store the ID to auto-select other tabs with the same title
|
// - store the ID to auto-select other tabs with the same title
|
||||||
*/
|
const selectTab = useCallback(
|
||||||
const onSelectTab = React.useCallback(
|
(tabId: string, manual = true) => {
|
||||||
(tab: TabsItem) => {
|
const tab = tabs.find((tab) => tab.id === tabId);
|
||||||
setTabsState((prev) => ({
|
|
||||||
activeIds: {
|
if (!tab) {
|
||||||
...prev.activeIds,
|
return;
|
||||||
[id]: tab.id,
|
}
|
||||||
},
|
|
||||||
activeTitles: tab.title
|
if (manual) {
|
||||||
? prev.activeTitles
|
touchedRef.current = true;
|
||||||
.filter((t) => t !== tab.title)
|
const href = `#${tab.id}`;
|
||||||
.concat([tab.title])
|
if (window.location.hash !== href) {
|
||||||
.slice(-TITLES_MAX)
|
router.replace(href, { scroll: false });
|
||||||
: prev.activeTitles,
|
}
|
||||||
}));
|
}
|
||||||
|
|
||||||
|
setTabsState((prev) => {
|
||||||
|
if (prev.activeIds[id] === tab.id) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
activeIds: {
|
||||||
|
...prev.activeIds,
|
||||||
|
[id]: tab.id,
|
||||||
|
},
|
||||||
|
activeTitles: tab.title
|
||||||
|
? prev.activeTitles
|
||||||
|
.filter((t) => t !== tab.title)
|
||||||
|
.concat([tab.title])
|
||||||
|
.slice(-TITLES_MAX)
|
||||||
|
: prev.activeTitles,
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[id, setTabsState]
|
[router, setTabsState, tabs, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
// When the hash changes, we try to select the tab containing the targetted element.
|
||||||
* When the hash changes, we try to select the tab containing the targetted element.
|
React.useLayoutEffect(() => {
|
||||||
*/
|
setInitialized(true);
|
||||||
React.useEffect(() => {
|
|
||||||
if (!hash) {
|
if (hash) {
|
||||||
|
// First check if the hash matches a tab ID.
|
||||||
|
const hashIsTab = tabs.some((tab) => tab.id === hash);
|
||||||
|
if (hashIsTab) {
|
||||||
|
selectTab(hash, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check if the hash matches an element inside a tab.
|
||||||
|
const activeElement = document.getElementById(hash);
|
||||||
|
if (!activeElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabPanel = activeElement.closest('[role="tabpanel"]');
|
||||||
|
if (!tabPanel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectTab(tabPanel.id, false);
|
||||||
|
}
|
||||||
|
}, [selectTab, tabs, hash]);
|
||||||
|
|
||||||
|
// Scroll to active element in the tab.
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
// If there is no hash or active tab, nothing to scroll.
|
||||||
|
if (!hash || !active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the tab is touched, we don't want to scroll.
|
||||||
|
if (touchedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the hash matches a tab, then the scroll is already done.
|
||||||
|
const hashIsTab = tabs.some((tab) => tab.id === hash);
|
||||||
|
if (hashIsTab) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,143 +187,237 @@ export function DynamicTabs(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabAncestor = activeElement.closest('[role="tabpanel"]');
|
activeElement.scrollIntoView({
|
||||||
if (!tabAncestor) {
|
block: 'start',
|
||||||
return;
|
behavior: 'instant',
|
||||||
}
|
});
|
||||||
|
}, [active, tabs, hash]);
|
||||||
const tab = tabs.find((tab) => getTabPanelId(tab.id) === tabAncestor.id);
|
|
||||||
if (!tab) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelectTab(tab);
|
|
||||||
}, [hash, tabs, onSelectTab]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'rounded-lg',
|
'rounded-lg',
|
||||||
'straight-corners:rounded-xs',
|
'straight-corners:rounded-xs',
|
||||||
'ring-1',
|
'ring-1 ring-tint-subtle ring-inset',
|
||||||
'ring-inset',
|
'flex min-w-0 flex-col',
|
||||||
'ring-tint-subtle',
|
className
|
||||||
'flex',
|
|
||||||
'flex-col',
|
|
||||||
'overflow-hidden',
|
|
||||||
style
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<TabItemList tabs={tabs} activeTabId={active?.id ?? null} onSelect={selectTab} />
|
||||||
role="tablist"
|
{tabs.map((tab) => (
|
||||||
className={tcls(
|
<TabPanel key={tab.id} tab={tab} isActive={tab.id === active?.id} />
|
||||||
'group/tabs',
|
|
||||||
'inline-flex',
|
|
||||||
'flex-row',
|
|
||||||
'self-stretch',
|
|
||||||
'after:flex-1',
|
|
||||||
'after:bg-tint-12/1',
|
|
||||||
// if last tab is selected, apply rounded to :after element
|
|
||||||
'[&:has(button.active-tab:last-of-type):after]:rounded-bl-md'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<div
|
|
||||||
key={tab.id}
|
|
||||||
className={tcls(
|
|
||||||
hashLinkButtonWrapperStyles,
|
|
||||||
'flex',
|
|
||||||
'items-center',
|
|
||||||
'gap-3.5',
|
|
||||||
|
|
||||||
//prev from active-tab
|
|
||||||
'[&:has(+_.active-tab)]:rounded-br-md',
|
|
||||||
|
|
||||||
//next from active-tab
|
|
||||||
'[.active-tab+&]:rounded-bl-md',
|
|
||||||
|
|
||||||
//next from active-tab
|
|
||||||
'[.active-tab_+_:after]:rounded-br-md',
|
|
||||||
|
|
||||||
'after:transition-colors',
|
|
||||||
'after:border-r',
|
|
||||||
'after:absolute',
|
|
||||||
'after:left-[unset]',
|
|
||||||
'after:right-0',
|
|
||||||
'after:border-tint',
|
|
||||||
'after:top-[15%]',
|
|
||||||
'after:h-[70%]',
|
|
||||||
'after:w-px',
|
|
||||||
|
|
||||||
'px-3.5',
|
|
||||||
'py-2',
|
|
||||||
|
|
||||||
'last:after:border-transparent',
|
|
||||||
|
|
||||||
'text-tint',
|
|
||||||
'bg-tint-12/1',
|
|
||||||
'hover:text-tint-strong',
|
|
||||||
'max-w-full',
|
|
||||||
'truncate',
|
|
||||||
|
|
||||||
active?.id === tab.id
|
|
||||||
? [
|
|
||||||
'shrink-0',
|
|
||||||
'active-tab',
|
|
||||||
'text-tint-strong',
|
|
||||||
'bg-transparent',
|
|
||||||
'[&.active-tab]:after:border-transparent',
|
|
||||||
'[:has(+_&.active-tab)]:after:border-transparent',
|
|
||||||
'[:has(&_+)]:after:border-transparent',
|
|
||||||
]
|
|
||||||
: null
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={active?.id === tab.id}
|
|
||||||
aria-controls={getTabPanelId(tab.id)}
|
|
||||||
id={getTabButtonId(tab.id)}
|
|
||||||
onClick={() => {
|
|
||||||
onSelectTab(tab);
|
|
||||||
}}
|
|
||||||
className={tcls(
|
|
||||||
'inline-block',
|
|
||||||
'text-sm',
|
|
||||||
'transition-[color]',
|
|
||||||
'font-medium',
|
|
||||||
'relative',
|
|
||||||
'max-w-full',
|
|
||||||
'truncate'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab.title}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<HashLinkButton
|
|
||||||
id={getTabButtonId(tab.id)}
|
|
||||||
block={block}
|
|
||||||
label="Direct link to tab"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{tabs.map((tab, index) => (
|
|
||||||
<div
|
|
||||||
key={tab.id}
|
|
||||||
role="tabpanel"
|
|
||||||
id={getTabPanelId(tab.id)}
|
|
||||||
aria-labelledby={getTabButtonId(tab.id)}
|
|
||||||
className={tcls('p-4', tab.id !== active?.id ? 'hidden' : null)}
|
|
||||||
>
|
|
||||||
{tabsBody[index]}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TabPanel = memo(function TabPanel(props: {
|
||||||
|
tab: TabsItem;
|
||||||
|
isActive: boolean;
|
||||||
|
}) {
|
||||||
|
const { tab, isActive } = props;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
id={tab.id}
|
||||||
|
aria-labelledby={getTabButtonId(tab.id)}
|
||||||
|
className="scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*20)]"
|
||||||
|
>
|
||||||
|
<div className="p-4" hidden={!isActive}>
|
||||||
|
{tab.body}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const TabItemList = memo(function TabItemList(props: {
|
||||||
|
tabs: TabsItem[];
|
||||||
|
activeTabId: string | null;
|
||||||
|
onSelect: (tabId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { tabs, activeTabId, onSelect } = props;
|
||||||
|
const { containerRef, itemRef, overflowing, isMeasuring } = useListOverflow();
|
||||||
|
const overflowingTabs = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(overflowing, (id) => {
|
||||||
|
const tabId = getTabIdFromButtonId(id);
|
||||||
|
return tabs.find((tab) => tab.id === tabId);
|
||||||
|
}).filter((x) => x !== undefined),
|
||||||
|
[overflowing, tabs]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
role="tablist"
|
||||||
|
className={tcls(
|
||||||
|
'group/tabs',
|
||||||
|
'overflow-hidden',
|
||||||
|
'rounded-t-lg',
|
||||||
|
'straight-corners:rounded-t-xs',
|
||||||
|
'inline-flex',
|
||||||
|
'self-stretch',
|
||||||
|
'after:flex-1',
|
||||||
|
'after:bg-tint-12/1',
|
||||||
|
// if last tab is selected, apply rounded to :after element
|
||||||
|
'[&:has(button.active-tab:last-of-type):after]:rounded-bl-md'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* When we measure, we add the menu at start to be sure everything's fit. */}
|
||||||
|
{isMeasuring ? (
|
||||||
|
<TabsDropdownMenu tabs={tabs} onSelect={onSelect} activeTabId={activeTabId} />
|
||||||
|
) : null}
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
// Hide overflowing tabs when not measuring.
|
||||||
|
if (overflowing.has(getTabButtonId(tab.id)) && !isMeasuring) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TabItem
|
||||||
|
key={tab.id}
|
||||||
|
ref={itemRef}
|
||||||
|
isActive={tab.id === activeTabId}
|
||||||
|
tab={tab}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Dropdown for overflowing tabs */}
|
||||||
|
{overflowingTabs.length > 0 && !isMeasuring ? (
|
||||||
|
<TabsDropdownMenu
|
||||||
|
tabs={overflowingTabs}
|
||||||
|
onSelect={onSelect}
|
||||||
|
activeTabId={activeTabId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function TabsDropdownMenu(props: {
|
||||||
|
tabs: TabsItem[];
|
||||||
|
activeTabId: string | null;
|
||||||
|
onSelect: (tabId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { tabs, onSelect, activeTabId } = props;
|
||||||
|
const language = useLanguage();
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
button={
|
||||||
|
<TabButton
|
||||||
|
isActive={tabs.some((tab) => tab.id === activeTabId)}
|
||||||
|
aria-label={tString(language, 'more')}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Icon icon="ellipsis" className="size-4" />
|
||||||
|
</TabButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => onSelect(tab.id)}
|
||||||
|
active={tab.id === activeTabId}
|
||||||
|
>
|
||||||
|
{tab.title}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab item that accepts a `tab` prop.
|
||||||
|
*/
|
||||||
|
const TabItem = memo(function TabItem(props: {
|
||||||
|
ref: React.Ref<HTMLButtonElement>;
|
||||||
|
isActive: boolean;
|
||||||
|
tab: TabsItem;
|
||||||
|
onSelect: (tabId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { ref, tab, isActive, onSelect } = props;
|
||||||
|
return (
|
||||||
|
<TabButton
|
||||||
|
ref={ref}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
aria-controls={tab.id}
|
||||||
|
id={getTabButtonId(tab.id)}
|
||||||
|
onClick={() => onSelect(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.title}
|
||||||
|
</TabButton>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic tab button component, low-level.
|
||||||
|
*/
|
||||||
|
function TabButton(
|
||||||
|
props: Omit<ComponentPropsWithRef<'button'>, 'type'> & {
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { isActive, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'relative',
|
||||||
|
'flex items-center',
|
||||||
|
|
||||||
|
//prev from active-tab
|
||||||
|
'[&:has(+_.active-tab)]:rounded-br-md',
|
||||||
|
|
||||||
|
//next from active-tab
|
||||||
|
'[.active-tab+&]:rounded-bl-md',
|
||||||
|
|
||||||
|
//next from active-tab
|
||||||
|
'[.active-tab_+_:after]:rounded-br-md',
|
||||||
|
|
||||||
|
'after:transition-colors',
|
||||||
|
'after:border-r',
|
||||||
|
'after:absolute',
|
||||||
|
'after:left-[unset]',
|
||||||
|
'after:right-0',
|
||||||
|
'after:border-tint',
|
||||||
|
'after:top-[15%]',
|
||||||
|
'after:h-[70%]',
|
||||||
|
'after:w-px',
|
||||||
|
|
||||||
|
'last:after:border-transparent',
|
||||||
|
|
||||||
|
'text-tint',
|
||||||
|
'bg-tint-12/1',
|
||||||
|
'hover:text-tint-strong',
|
||||||
|
'max-w-full',
|
||||||
|
'shrink-0',
|
||||||
|
'truncate',
|
||||||
|
|
||||||
|
props['aria-selected'] || props['aria-expanded'] || isActive
|
||||||
|
? [
|
||||||
|
'active-tab',
|
||||||
|
'text-tint-strong',
|
||||||
|
'bg-transparent',
|
||||||
|
'[&.active-tab]:after:border-transparent',
|
||||||
|
'[:has(+_&.active-tab)]:after:border-transparent',
|
||||||
|
'[:has(&_+)]:after:border-transparent',
|
||||||
|
]
|
||||||
|
: null
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
{...rest}
|
||||||
|
type="button"
|
||||||
|
className={tcls(
|
||||||
|
'relative inline-block max-w-full truncate px-3.5 py-2 font-medium text-sm transition-[color]',
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the ID for a tab button.
|
* Get the ID for a tab button.
|
||||||
*/
|
*/
|
||||||
@@ -272,17 +426,25 @@ function getTabButtonId(tabId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the ID for a tab panel.
|
* Get the ID of a tab from a button ID.
|
||||||
* We use the ID of the tab itself as links can be pointing to this ID.
|
|
||||||
*/
|
*/
|
||||||
function getTabPanelId(tabId: string) {
|
function getTabIdFromButtonId(buttonId: string) {
|
||||||
return tabId;
|
if (buttonId.startsWith('tab-')) {
|
||||||
|
return buttonId.slice(4);
|
||||||
|
}
|
||||||
|
return buttonId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get explicitly selected tab in a set of tabs.
|
* Get explicitly selected tab in a set of tabs.
|
||||||
*/
|
*/
|
||||||
function getTabBySelection(input: TabsInput, state: TabsState): TabsItem | null {
|
function getTabBySelection(
|
||||||
|
input: {
|
||||||
|
id: string;
|
||||||
|
tabs: TabsItem[];
|
||||||
|
},
|
||||||
|
state: TabsState
|
||||||
|
): TabsItem | null {
|
||||||
const activeId = state.activeIds[input.id];
|
const activeId = state.activeIds[input.id];
|
||||||
return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null;
|
return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null;
|
||||||
}
|
}
|
||||||
@@ -290,7 +452,13 @@ function getTabBySelection(input: TabsInput, state: TabsState): TabsItem | null
|
|||||||
/**
|
/**
|
||||||
* Get the best selected tab in a set of tabs by taking only title into account.
|
* Get the best selected tab in a set of tabs by taking only title into account.
|
||||||
*/
|
*/
|
||||||
function getTabByTitle(input: TabsInput, state: TabsState): TabsItem | null {
|
function getTabByTitle(
|
||||||
|
input: {
|
||||||
|
id: string;
|
||||||
|
tabs: TabsItem[];
|
||||||
|
},
|
||||||
|
state: TabsState
|
||||||
|
): TabsItem | null {
|
||||||
return (
|
return (
|
||||||
input.tabs
|
input.tabs
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
|
|||||||
@@ -9,47 +9,40 @@ import { DynamicTabs, type TabsItem } from './DynamicTabs';
|
|||||||
export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
||||||
const { block, ancestorBlocks, document, style, context } = props;
|
const { block, ancestorBlocks, document, style, context } = props;
|
||||||
|
|
||||||
const tabs: TabsItem[] = [];
|
if (!block.key) {
|
||||||
const tabsBody: React.ReactNode[] = [];
|
throw new Error('Tabs block is missing a key');
|
||||||
|
|
||||||
block.nodes.forEach((tab, index) => {
|
|
||||||
tabs.push({
|
|
||||||
id: tab.meta?.id ?? tab.key!,
|
|
||||||
title: tab.data.title ?? '',
|
|
||||||
});
|
|
||||||
|
|
||||||
tabsBody.push(
|
|
||||||
<Blocks
|
|
||||||
key={tab.key ?? index}
|
|
||||||
nodes={tab.nodes}
|
|
||||||
document={document}
|
|
||||||
ancestorBlocks={[...ancestorBlocks, block, tab]}
|
|
||||||
context={context}
|
|
||||||
blockStyle={tcls('flip-heading-hash')}
|
|
||||||
style={tcls('w-full', 'space-y-4')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (context.mode === 'print') {
|
|
||||||
// When printing, we display the tab, one after the other
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{tabs.map((tab, index) => (
|
|
||||||
<DynamicTabs
|
|
||||||
key={tab.id}
|
|
||||||
id={block.key!}
|
|
||||||
block={block}
|
|
||||||
tabs={[tab]}
|
|
||||||
tabsBody={[tabsBody[index]]}
|
|
||||||
style={style}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const id = block.key;
|
||||||
<DynamicTabs id={block.key!} block={block} tabs={tabs} tabsBody={tabsBody} style={style} />
|
|
||||||
);
|
const tabs: TabsItem[] = block.nodes.map((tab) => {
|
||||||
|
if (!tab.key) {
|
||||||
|
throw new Error('Tab block is missing a key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tab.meta?.id ?? tab.key,
|
||||||
|
title: tab.data.title ?? '',
|
||||||
|
body: (
|
||||||
|
<Blocks
|
||||||
|
key={tab.key}
|
||||||
|
nodes={tab.nodes}
|
||||||
|
document={document}
|
||||||
|
ancestorBlocks={[...ancestorBlocks, block, tab]}
|
||||||
|
context={context}
|
||||||
|
blockStyle="flip-heading-hash"
|
||||||
|
style="w-full space-y-4"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// When printing, we display the tab, one after the other
|
||||||
|
if (context.mode === 'print') {
|
||||||
|
return tabs.map((tab) => {
|
||||||
|
return <DynamicTabs key={tab.id} id={id} tabs={[tab]} className={tcls(style)} />;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DynamicTabs id={id} tabs={tabs} className={tcls(style)} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function Footer(props: { context: GitBookSiteContext }) {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}}
|
}}
|
||||||
priority="lazy"
|
loading="lazy"
|
||||||
style={[
|
style={[
|
||||||
'w-auto',
|
'w-auto',
|
||||||
'max-w-40',
|
'max-w-40',
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export function CurrentContentIcon(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
preload
|
||||||
{...imageProps}
|
{...imageProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 type { SiteSpace } from '@gitbook/api';
|
||||||
import { SearchContainer } from '../Search';
|
import { SearchContainer } from '../Search';
|
||||||
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
|
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
|
||||||
import { HeaderLink } from './HeaderLink';
|
import { HeaderLink } from './HeaderLink';
|
||||||
@@ -18,9 +19,12 @@ import { TranslationsDropdown } from './SpacesDropdown';
|
|||||||
export function Header(props: {
|
export function Header(props: {
|
||||||
context: GitBookSiteContext;
|
context: GitBookSiteContext;
|
||||||
withTopHeader?: boolean;
|
withTopHeader?: boolean;
|
||||||
withVariants?: 'generic' | 'translations';
|
variants: {
|
||||||
|
generic: SiteSpace[];
|
||||||
|
translations: SiteSpace[];
|
||||||
|
};
|
||||||
}) {
|
}) {
|
||||||
const { context, withTopHeader, withVariants } = props;
|
const { context, withTopHeader, variants } = props;
|
||||||
const { siteSpace, siteSpaces, sections, customization } = context;
|
const { siteSpace, siteSpaces, sections, customization } = context;
|
||||||
|
|
||||||
const withSections = Boolean(
|
const withSections = Boolean(
|
||||||
@@ -91,7 +95,7 @@ export function Header(props: {
|
|||||||
'theme-bold:text-header-link',
|
'theme-bold:text-header-link',
|
||||||
'hover:bg-tint-hover',
|
'hover:bg-tint-hover',
|
||||||
'hover:theme-bold:bg-header-link/3',
|
'hover:theme-bold:bg-header-link/3',
|
||||||
withVariants === 'generic'
|
variants.generic.length > 1
|
||||||
? 'xl:hidden'
|
? 'xl:hidden'
|
||||||
: 'page-no-toc:hidden lg:hidden'
|
: 'page-no-toc:hidden lg:hidden'
|
||||||
)}
|
)}
|
||||||
@@ -126,14 +130,14 @@ export function Header(props: {
|
|||||||
>
|
>
|
||||||
<SearchContainer
|
<SearchContainer
|
||||||
style={customization.styling.search}
|
style={customization.styling.search}
|
||||||
withVariants={withVariants === 'generic'}
|
withVariants={variants.generic.length > 1}
|
||||||
withSiteVariants={
|
withSiteVariants={
|
||||||
sections?.list.some(
|
sections?.list.some(
|
||||||
(s) =>
|
(s) =>
|
||||||
s.object === 'site-section' && s.siteSpaces.length > 1
|
s.object === 'site-section' && s.siteSpaces.length > 1
|
||||||
) ?? false
|
) ?? false
|
||||||
}
|
}
|
||||||
withSections={!!sections}
|
withSections={sections ? sections.list.length > 1 : false}
|
||||||
section={
|
section={
|
||||||
sections
|
sections
|
||||||
? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear
|
? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear
|
||||||
@@ -150,7 +154,7 @@ export function Header(props: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{customization.header.links.length > 0 ||
|
{customization.header.links.length > 0 ||
|
||||||
(!withSections && withVariants === 'translations') ? (
|
(!withSections && variants.translations.length > 1) ? (
|
||||||
<HeaderLinks>
|
<HeaderLinks>
|
||||||
{customization.header.links.length > 0 ? (
|
{customization.header.links.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
@@ -170,11 +174,15 @@ export function Header(props: {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{!withSections && withVariants === 'translations' ? (
|
{!withSections && variants.translations.length > 1 ? (
|
||||||
<TranslationsDropdown
|
<TranslationsDropdown
|
||||||
context={context}
|
context={context}
|
||||||
siteSpace={siteSpace}
|
siteSpace={
|
||||||
siteSpaces={siteSpaces}
|
variants.translations.find(
|
||||||
|
(space) => space.id === siteSpace.id
|
||||||
|
) ?? siteSpace
|
||||||
|
}
|
||||||
|
siteSpaces={variants.translations}
|
||||||
className="flex! theme-bold:text-header-link hover:theme-bold:bg-header-link/3"
|
className="flex! theme-bold:text-header-link hover:theme-bold:bg-header-link/3"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -187,14 +195,17 @@ export function Header(props: {
|
|||||||
{sections && withSections ? (
|
{sections && withSections ? (
|
||||||
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||||
<SiteSectionTabs sections={encodeClientSiteSections(context, sections)}>
|
<SiteSectionTabs sections={encodeClientSiteSections(context, sections)}>
|
||||||
{withVariants === 'translations' ? (
|
{variants.translations.length > 1 ? (
|
||||||
<div className="before:contents[] flex self-start py-2 before:mr-4 before:border-tint before:border-l">
|
<TranslationsDropdown
|
||||||
<TranslationsDropdown
|
context={context}
|
||||||
context={context}
|
siteSpace={
|
||||||
siteSpace={siteSpace}
|
variants.translations.find(
|
||||||
siteSpaces={siteSpaces}
|
(space) => space.id === siteSpace.id
|
||||||
/>
|
) ?? siteSpace
|
||||||
</div>
|
}
|
||||||
|
siteSpaces={variants.translations}
|
||||||
|
className="my-2 ml-2 self-start"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</SiteSectionTabs>
|
</SiteSectionTabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export async function HeaderLogo(props: HeaderLogoProps) {
|
|||||||
width: 260,
|
width: 260,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
priority="high"
|
preload
|
||||||
style={tcls(
|
style={tcls(
|
||||||
'overflow-hidden',
|
'overflow-hidden',
|
||||||
'shrink',
|
'shrink',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
|||||||
import assertNever from 'assert-never';
|
import assertNever from 'assert-never';
|
||||||
import QuickLRU from 'quick-lru';
|
import QuickLRU from 'quick-lru';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { create } from 'zustand';
|
import { createStore, useStore } from 'zustand';
|
||||||
|
|
||||||
type PageActionType = 'button' | 'dropdown-menu-item';
|
type PageActionType = 'button' | 'dropdown-menu-item';
|
||||||
|
|
||||||
@@ -41,18 +41,13 @@ export function ActionOpenAssistant(props: { assistant: Assistant; type: PageAct
|
|||||||
type CopiedStore = {
|
type CopiedStore = {
|
||||||
copied: boolean;
|
copied: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
copy: (data: string, opts?: { onSuccess?: () => void }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
// We need to store everything in a store to share the state between every instance of the component.
|
const createCopiedStateStore = () => {
|
||||||
const useCopiedStore = create<
|
|
||||||
CopiedStore & {
|
|
||||||
setLoading: (loading: boolean) => void;
|
|
||||||
copy: (data: string, opts?: { onSuccess?: () => void }) => void;
|
|
||||||
}
|
|
||||||
>((set) => {
|
|
||||||
let timeoutRef: ReturnType<typeof setTimeout> | null = null;
|
let timeoutRef: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
return createStore<CopiedStore>()((set) => ({
|
||||||
return {
|
|
||||||
copied: false,
|
copied: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
setLoading: (loading: boolean) => set({ loading }),
|
setLoading: (loading: boolean) => set({ loading }),
|
||||||
@@ -70,13 +65,25 @@ const useCopiedStore = create<
|
|||||||
timeoutRef = setTimeout(() => {
|
timeoutRef = setTimeout(() => {
|
||||||
set({ copied: false });
|
set({ copied: false });
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
|
|
||||||
// Reset the timeout ref to avoid multiple timeouts
|
|
||||||
timeoutRef = null;
|
timeoutRef = null;
|
||||||
}, 1500);
|
}, 1500);
|
||||||
},
|
},
|
||||||
};
|
}));
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const copiedStores = new Map<string, ReturnType<typeof createCopiedStateStore>>();
|
||||||
|
|
||||||
|
const getOrCreateCopiedStoreByKey = (storeKey: string) => {
|
||||||
|
const existing = copiedStores.get(storeKey);
|
||||||
|
if (existing) return existing;
|
||||||
|
const created = createCopiedStateStore();
|
||||||
|
copiedStores.set(storeKey, created);
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useCopiedStore(stateKey: string) {
|
||||||
|
return useStore(getOrCreateCopiedStoreByKey(stateKey));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache for the markdown versbion of the page.
|
* Cache for the markdown versbion of the page.
|
||||||
@@ -96,7 +103,7 @@ export function ActionCopyMarkdown(props: {
|
|||||||
|
|
||||||
const closeDropdown = useDropdownMenuClose();
|
const closeDropdown = useDropdownMenuClose();
|
||||||
|
|
||||||
const { copied, loading, setLoading, copy } = useCopiedStore();
|
const { copied, loading, setLoading, copy } = useCopiedStore('markdown');
|
||||||
|
|
||||||
// Fetch the markdown from the page
|
// Fetch the markdown from the page
|
||||||
const fetchMarkdown = async () => {
|
const fetchMarkdown = async () => {
|
||||||
@@ -293,14 +300,29 @@ export function CopyToClipboard(props: {
|
|||||||
icon: IconName;
|
icon: IconName;
|
||||||
}) {
|
}) {
|
||||||
const { type, data, label, description, icon } = props;
|
const { type, data, label, description, icon } = props;
|
||||||
|
|
||||||
|
const closeDropdown = useDropdownMenuClose();
|
||||||
|
|
||||||
|
const language = useLanguage();
|
||||||
|
const labelKey = label.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
const { copied, copy } = useCopiedStore(labelKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageActionWrapper
|
<PageActionWrapper
|
||||||
type={type}
|
type={type}
|
||||||
icon={icon}
|
icon={copied ? 'check' : icon}
|
||||||
label={label}
|
label={copied ? tString(language, 'code_copied') : label}
|
||||||
description={description}
|
description={description}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
navigator.clipboard.writeText(data);
|
e.preventDefault();
|
||||||
|
|
||||||
|
copy(data, {
|
||||||
|
onSuccess: () => {
|
||||||
|
if (type === 'dropdown-menu-item') {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { PageFooterNavigation } from './PageFooterNavigation';
|
|||||||
import { PageHeader } from './PageHeader';
|
import { PageHeader } from './PageHeader';
|
||||||
import { PreservePageLayout } from './PreservePageLayout';
|
import { PreservePageLayout } from './PreservePageLayout';
|
||||||
|
|
||||||
const LINK_PREVIEW_MAX_COUNT = 100;
|
const LINK_PREVIEW_MAX_COUNT = 500;
|
||||||
|
|
||||||
export function PageBody(props: {
|
export function PageBody(props: {
|
||||||
context: GitBookSiteContext;
|
context: GitBookSiteContext;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const container = useResizeObserver({
|
const container = useResizeObserver({
|
||||||
|
// @ts-expect-error wrong types
|
||||||
ref: containerRef,
|
ref: containerRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,4 +27,6 @@ export function PageIcon(props: {
|
|||||||
if (page.icon) {
|
if (page.icon) {
|
||||||
return <Icon icon={page.icon as IconName} className={tcls('size-[1em]', style)} />;
|
return <Icon icon={page.icon as IconName} className={tcls('size-[1em]', style)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useLanguage } from '@/intl/client';
|
||||||
|
import { t, tString } from '@/intl/translate';
|
||||||
|
import type { TranslationLanguage } from '@/intl/translations';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import { readStreamableValue } from 'ai/rsc';
|
import { readStreamableValue } from 'ai/rsc';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Loading } from '@/components/primitives';
|
import { AIResponseFeedback, AISearchIcon } from '../AIChat';
|
||||||
import { useLanguage } from '@/intl/client';
|
import { HoldMessage } from '../AIChat/AIChatMessages';
|
||||||
import { t } from '@/intl/translate';
|
|
||||||
import type { TranslationLanguage } from '@/intl/translations';
|
|
||||||
import { tcls } from '@/lib/tailwind';
|
|
||||||
|
|
||||||
import { AIResponseFeedback } from '../AIChat';
|
|
||||||
import { useTrackEvent } from '../Insights';
|
import { useTrackEvent } from '../Insights';
|
||||||
import { Link } from '../primitives';
|
import { Button, Link } from '../primitives';
|
||||||
import { useSearchAskContext } from './SearchAskContext';
|
import { useSearchAskContext } from './SearchAskContext';
|
||||||
import { type AskAnswerResult, type AskAnswerSource, streamAskQuestion } from './server-actions';
|
import { type AskAnswerResult, type AskAnswerSource, streamAskQuestion } from './server-actions';
|
||||||
import { useSearchLink } from './useSearch';
|
import { useSearch, useSearchLink } from './useSearch';
|
||||||
|
|
||||||
export type SearchAskState =
|
export type SearchAskState =
|
||||||
| {
|
| {
|
||||||
@@ -84,8 +83,11 @@ export function SearchAskAnswer(props: { query: string }) {
|
|||||||
}, [setAskState]);
|
}, [setAskState]);
|
||||||
|
|
||||||
const loading = (
|
const loading = (
|
||||||
<div className="flex grow items-center justify-center">
|
<div className="flex grow flex-col items-center justify-center">
|
||||||
<Loading className={tcls('size-6', 'text-tint/6')} />
|
<div className="relative">
|
||||||
|
<AISearchIcon state="thinking" className="size-6 text-tint/6" />
|
||||||
|
</div>
|
||||||
|
<HoldMessage breakLines={true} className="text-center" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -129,8 +131,29 @@ function TransitionAnswerBody(props: {
|
|||||||
});
|
});
|
||||||
}, [answer]);
|
}, [answer]);
|
||||||
|
|
||||||
|
const language = useLanguage();
|
||||||
|
const [, setSearchState] = useSearch();
|
||||||
|
|
||||||
return display ? (
|
return display ? (
|
||||||
<div className={tcls('w-full')}>
|
<div className={tcls('flex w-full flex-col gap-4')}>
|
||||||
|
<div className="-mt-2 -mr-2 relative flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
icon="chevron-left"
|
||||||
|
variant="blank"
|
||||||
|
iconOnly
|
||||||
|
label={tString(language, 'search_back')}
|
||||||
|
className="-ml-2 starting:-mr-2 starting:ml-0 starting:w-0 starting:px-0 starting:opacity-0 transition-[all,margin,padding,width,opacity] transition-discrete delay-[0s,2s,2s,2s,2.5s] duration-[.3s,1s,1s,1s,.5s]"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchState((prev) =>
|
||||||
|
prev ? { ...prev, query: prev.ask, ask: null } : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AISearchIcon state="intro" className="size-4 text-tint/6" />
|
||||||
|
<span className="animate-[fadeIn_1s_.5s_both] font-semibold text-tint">
|
||||||
|
{t(language, 'ai_answer')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<AnswerBody query={query} answer={display} />
|
<AnswerBody query={query} answer={display} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -144,7 +167,7 @@ function AnswerBody(props: { query: string; answer: AskAnswerResult }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div data-testid="search-ask-answer" className="text-tint-strong">
|
<div data-testid="search-ask-answer" className="animate-fade-in-slow text-tint-strong">
|
||||||
{answer.body ?? t(language, 'search_ask_no_answer')}
|
{answer.body ?? t(language, 'search_ask_no_answer')}
|
||||||
{answer.sources.length > 0 ? (
|
{answer.sources.length > 0 ? (
|
||||||
// @TODO: Add responseId once search uses new AI endpoint
|
// @TODO: Add responseId once search uses new AI endpoint
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useSearchAskState } from './SearchAskContext';
|
|||||||
import { SearchAskProvider } from './SearchAskContext';
|
import { SearchAskProvider } from './SearchAskContext';
|
||||||
import { SearchInput } from './SearchInput';
|
import { SearchInput } from './SearchInput';
|
||||||
import { SearchResults, type SearchResultsRef } from './SearchResults';
|
import { SearchResults, type SearchResultsRef } from './SearchResults';
|
||||||
import { SearchScopeToggle } from './SearchScopeToggle';
|
import { SearchScopeControl } from './SearchScopeControl';
|
||||||
import { useSearch } from './useSearch';
|
import { useSearch } from './useSearch';
|
||||||
import { useSearchResults } from './useSearchResults';
|
import { useSearchResults } from './useSearchResults';
|
||||||
import { useSearchResultsCursor } from './useSearchResultsCursor';
|
import { useSearchResultsCursor } from './useSearchResultsCursor';
|
||||||
@@ -181,7 +181,7 @@ export function SearchContainer(props: SearchContainerProps) {
|
|||||||
const visible = viewport === 'desktop' ? !isMobile : viewport === 'mobile' ? isMobile : true;
|
const visible = viewport === 'desktop' ? !isMobile : viewport === 'mobile' ? isMobile : true;
|
||||||
|
|
||||||
const searchResultsId = `search-results-${React.useId()}`;
|
const searchResultsId = `search-results-${React.useId()}`;
|
||||||
const { results, fetching } = useSearchResults({
|
const { results, fetching, error } = useSearchResults({
|
||||||
disabled: !(state?.query || withAI),
|
disabled: !(state?.query || withAI),
|
||||||
query: normalizedQuery,
|
query: normalizedQuery,
|
||||||
siteSpaceId,
|
siteSpaceId,
|
||||||
@@ -215,26 +215,31 @@ export function SearchContainer(props: SearchContainerProps) {
|
|||||||
// Only show content if there's a query or Ask is enabled
|
// Only show content if there's a query or Ask is enabled
|
||||||
state?.query || withAI ? (
|
state?.query || withAI ? (
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
|
<div className="scroll-py-2 overflow-y-scroll p-2">
|
||||||
|
{state !== null && !showAsk ? (
|
||||||
|
<SearchResults
|
||||||
|
ref={resultsRef}
|
||||||
|
query={normalizedQuery}
|
||||||
|
id={searchResultsId}
|
||||||
|
fetching={fetching}
|
||||||
|
results={results}
|
||||||
|
cursor={cursor}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
|
||||||
|
</div>
|
||||||
{(withVariants || withSections) && !showAsk ? (
|
{(withVariants || withSections) && !showAsk ? (
|
||||||
<SearchScopeToggle
|
<div className="border-tint-subtle border-t bg-tint-subtle px-4 py-1.5">
|
||||||
section={section}
|
<SearchScopeControl
|
||||||
spaceTitle={spaceTitle}
|
section={section}
|
||||||
withVariants={withVariants}
|
spaceTitle={spaceTitle}
|
||||||
withSiteVariants={withSiteVariants}
|
withVariants={withVariants}
|
||||||
withSections={withSections}
|
withSiteVariants={withSiteVariants}
|
||||||
/>
|
withSections={withSections}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{state !== null && !showAsk ? (
|
|
||||||
<SearchResults
|
|
||||||
ref={resultsRef}
|
|
||||||
query={normalizedQuery}
|
|
||||||
id={searchResultsId}
|
|
||||||
fetching={fetching}
|
|
||||||
results={results}
|
|
||||||
cursor={cursor}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
|
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
@@ -246,7 +251,7 @@ export function SearchContainer(props: SearchContainerProps) {
|
|||||||
onOpenAutoFocus: (event) => event.preventDefault(),
|
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||||
align: 'start',
|
align: 'start',
|
||||||
className:
|
className:
|
||||||
'@container overflow-y-scroll bg-tint-base has-[.empty]:hidden scroll-py-2 w-128 p-2 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
|
'@container flex flex-col bg-tint-base has-[.empty]:hidden w-128 p-0 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
|
||||||
onInteractOutside: (event) => {
|
onInteractOutside: (event) => {
|
||||||
// Don't close if clicking on the search input itself
|
// Don't close if clicking on the search input itself
|
||||||
if (searchInputRef.current?.contains(event.target as Node)) {
|
if (searchInputRef.current?.contains(event.target as Node)) {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { tString, useLanguage } from '@/intl/client';
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import { Button, variantClasses } from '../primitives';
|
import { Button, variantClasses } from '../primitives';
|
||||||
|
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||||
import { useClassnames } from '../primitives/StyleProvider';
|
import { useClassnames } from '../primitives/StyleProvider';
|
||||||
|
|
||||||
interface SearchInputProps {
|
interface SearchInputProps {
|
||||||
@@ -79,11 +80,11 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
|||||||
{value && isOpen ? (
|
{value && isOpen ? (
|
||||||
<Button
|
<Button
|
||||||
variant="blank"
|
variant="blank"
|
||||||
label="Clear"
|
label={tString(language, 'clear')}
|
||||||
size="medium"
|
size="medium"
|
||||||
iconOnly
|
iconOnly
|
||||||
icon="circle-xmark"
|
icon="circle-xmark"
|
||||||
className="-ml-1.5 -mr-1 animate-scale-in px-1.5"
|
className="-ml-1.5 -mr-1 animate-scale-in px-1.5 theme-bold:text-header-link theme-bold:hover:bg-header-link/3"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange('');
|
onChange('');
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
@@ -120,47 +121,12 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
|||||||
// Forward
|
// Forward
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
/>
|
/>
|
||||||
{!isOpen ? <Shortcut /> : null}
|
<KeyboardShortcut
|
||||||
|
keys={isOpen ? ['esc'] : ['mod', 'k']}
|
||||||
|
className="last:-mr-1 theme-bold:border-header-link/5 theme-bold:bg-header-background theme-bold:text-header-link"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function getOperatingSystem() {
|
|
||||||
const platform = navigator.platform.toLowerCase();
|
|
||||||
|
|
||||||
if (platform.includes('mac')) return 'mac';
|
|
||||||
if (platform.includes('win')) return 'win';
|
|
||||||
|
|
||||||
return 'win';
|
|
||||||
}
|
|
||||||
|
|
||||||
function Shortcut() {
|
|
||||||
const [operatingSystem, setOperatingSystem] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOperatingSystem(getOperatingSystem());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-busy={operatingSystem === null ? 'true' : undefined}
|
|
||||||
className={tcls(
|
|
||||||
`shortcut -mr-1 relative z-10 hidden justify-end gap-0.5 whitespace-nowrap text-xs [font-feature-settings:"calt","case"] after:absolute after:right-full after:z-20 after:h-full after:w-8 after:bg-linear-to-r after:from-transparent after:to-tint-base theme-bold:after:to-transparent after:content-[''] contrast-more:text-tint-strong md:flex`,
|
|
||||||
operatingSystem
|
|
||||||
? 'motion-safe:animate-fade-in motion-reduce:opacity-11'
|
|
||||||
: 'opacity-0'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<kbd
|
|
||||||
className={`flex h-5 min-w-5 items-center justify-center rounded-sm border border-tint-subtle theme-bold:border-header-link/5 bg-tint-base theme-bold:bg-header-background px-1 ${operatingSystem === 'mac' ? 'text-sm' : ''}`}
|
|
||||||
>
|
|
||||||
{operatingSystem === 'mac' ? '⌘' : 'Ctrl'}
|
|
||||||
</kbd>
|
|
||||||
<kbd className="flex size-5 items-center justify-center rounded-sm border border-tint-subtle theme-bold:border-header-link/5 bg-tint-base theme-bold:bg-header-background px-1">
|
|
||||||
K
|
|
||||||
</kbd>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { type Assistant, useAI } from '@/components/AI';
|
|||||||
import { t, useLanguage } from '@/intl/client';
|
import { t, useLanguage } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
import { Loading } from '../primitives';
|
import { Button, Loading } from '../primitives';
|
||||||
import { SearchPageResultItem } from './SearchPageResultItem';
|
import { SearchPageResultItem } from './SearchPageResultItem';
|
||||||
import { SearchQuestionResultItem } from './SearchQuestionResultItem';
|
import { SearchQuestionResultItem } from './SearchQuestionResultItem';
|
||||||
import { SearchSectionResultItem } from './SearchSectionResultItem';
|
import { SearchSectionResultItem } from './SearchSectionResultItem';
|
||||||
@@ -36,10 +36,11 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
results: ResultType[];
|
results: ResultType[];
|
||||||
fetching: boolean;
|
fetching: boolean;
|
||||||
cursor: number | null;
|
cursor: number | null;
|
||||||
|
error: boolean;
|
||||||
},
|
},
|
||||||
ref: React.Ref<SearchResultsRef>
|
ref: React.Ref<SearchResultsRef>
|
||||||
) {
|
) {
|
||||||
const { children, id, query, results, fetching, cursor } = props;
|
const { children, id, query, results, fetching, cursor, error } = props;
|
||||||
|
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
|
||||||
@@ -82,6 +83,32 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'flex',
|
||||||
|
'flex-col',
|
||||||
|
'items-center',
|
||||||
|
'justify-center',
|
||||||
|
'text-center',
|
||||||
|
'py-8',
|
||||||
|
'h-full',
|
||||||
|
'gap-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>{t(language, 'search_ask_error')}</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
// We do a reload because in case of a new deployment, the action might have changed and it requires a full reload to work again.
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
{t(language, 'unexpected_error_retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const noResults = (
|
const noResults = (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { t, useLanguage } from '@/intl/client';
|
||||||
|
import type { SiteSection } from '@gitbook/api';
|
||||||
|
import { Button, DropdownChevron, DropdownMenu, DropdownMenuItem } from '../primitives';
|
||||||
|
import { useSearch } from './useSearch';
|
||||||
|
|
||||||
|
interface SearchScopeControlProps {
|
||||||
|
spaceTitle: string;
|
||||||
|
section?: Pick<SiteSection, 'title' | 'icon'>;
|
||||||
|
withVariants: boolean;
|
||||||
|
withSiteVariants: boolean;
|
||||||
|
withSections: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toolbar to toggle between search modes (global or scoped to a space).
|
||||||
|
* Only visible when the space is in a collection.
|
||||||
|
*/
|
||||||
|
export function SearchScopeControl(props: SearchScopeControlProps) {
|
||||||
|
const { withVariants, withSections } = props;
|
||||||
|
|
||||||
|
const [state] = useSearch();
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether to include all variants in the search
|
||||||
|
const sectionScopeIsExtended = ['default', 'all'].includes(state.scope);
|
||||||
|
const variantScopeIsExtended = ['extended', 'all'].includes(state.scope);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="mr-1">{t(language, 'search_scope_title')}</span>
|
||||||
|
{withSections ? (
|
||||||
|
<SearchScopeSectionControl isExtended={sectionScopeIsExtended} {...props} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{withVariants && (!withSections || !sectionScopeIsExtended) ? (
|
||||||
|
<SearchScopeVariantControl isExtended={variantScopeIsExtended} {...props} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchScopeSectionControl(props: SearchScopeControlProps & { isExtended: boolean }) {
|
||||||
|
const { isExtended, section } = props;
|
||||||
|
|
||||||
|
const language = useLanguage();
|
||||||
|
const [, setSearchState] = useSearch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
button={
|
||||||
|
<Button
|
||||||
|
variant="blank"
|
||||||
|
size="medium"
|
||||||
|
className="px-2 text-tint-strong"
|
||||||
|
icon={isExtended ? undefined : section?.icon}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{t(
|
||||||
|
language,
|
||||||
|
isExtended
|
||||||
|
? 'search_scope_section_all'
|
||||||
|
: 'search_scope_section_current',
|
||||||
|
section?.title ?? ''
|
||||||
|
)}
|
||||||
|
<DropdownChevron />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
leadingIcon="infinity"
|
||||||
|
className="gap-3"
|
||||||
|
active={isExtended}
|
||||||
|
onClick={() =>
|
||||||
|
setSearchState((prev) => (prev ? { ...prev, scope: 'default' } : null))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-tint-strong">
|
||||||
|
{t(language, 'search_scope_section_all')}
|
||||||
|
</span>
|
||||||
|
<span className="text-tint-subtle text-xs">
|
||||||
|
{t(language, 'search_scope_section_all_description')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
leadingIcon={section?.icon ?? 'crosshairs'}
|
||||||
|
className="gap-3"
|
||||||
|
active={!isExtended}
|
||||||
|
onClick={() =>
|
||||||
|
setSearchState((prev) => (prev ? { ...prev, scope: 'current' } : null))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-tint-strong">
|
||||||
|
{t(language, 'search_scope_section_current', section?.title ?? '')}
|
||||||
|
</span>
|
||||||
|
<span className="text-tint-subtle text-xs">
|
||||||
|
{t(language, 'search_scope_section_current_description')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchScopeVariantControl(props: SearchScopeControlProps & { isExtended: boolean }) {
|
||||||
|
const { isExtended, spaceTitle, withSections } = props;
|
||||||
|
|
||||||
|
const language = useLanguage();
|
||||||
|
const [, setSearchState] = useSearch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
button={
|
||||||
|
<Button variant="blank" size="medium" className="px-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-tint-strong">
|
||||||
|
{t(
|
||||||
|
language,
|
||||||
|
isExtended
|
||||||
|
? 'search_scope_variant_all'
|
||||||
|
: 'search_scope_variant_current',
|
||||||
|
spaceTitle ?? ''
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<DropdownChevron />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="gap-3"
|
||||||
|
active={!isExtended}
|
||||||
|
onClick={() =>
|
||||||
|
setSearchState((prev) =>
|
||||||
|
prev ? { ...prev, scope: withSections ? 'current' : 'default' } : null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-tint-strong">
|
||||||
|
{t(language, 'search_scope_variant_current', spaceTitle ?? '')}
|
||||||
|
</span>
|
||||||
|
<span className="text-tint-subtle text-xs">
|
||||||
|
{t(language, 'search_scope_variant_current_description')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="gap-3"
|
||||||
|
active={isExtended}
|
||||||
|
onClick={() =>
|
||||||
|
setSearchState((prev) => (prev ? { ...prev, scope: 'extended' } : null))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-tint-strong">
|
||||||
|
{t(language, 'search_scope_variant_all')}
|
||||||
|
</span>
|
||||||
|
<span className="text-tint-subtle text-xs">
|
||||||
|
{t(language, 'search_scope_variant_all_description')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { tString, useLanguage } from '@/intl/client';
|
|
||||||
import type { SiteSection } from '@gitbook/api';
|
|
||||||
import { Button, Checkbox, DropdownMenu, DropdownMenuItem } from '../primitives';
|
|
||||||
import { SegmentedControl, SegmentedControlItem } from '../primitives/SegmentedControl';
|
|
||||||
import { useSearch } from './useSearch';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toolbar to toggle between search modes (global or scoped to a space).
|
|
||||||
* Only visible when the space is in a collection.
|
|
||||||
*/
|
|
||||||
export function SearchScopeToggle(props: {
|
|
||||||
spaceTitle: string;
|
|
||||||
section?: Pick<SiteSection, 'title' | 'icon'>;
|
|
||||||
withVariants: boolean;
|
|
||||||
withSiteVariants: boolean;
|
|
||||||
withSections: boolean;
|
|
||||||
}) {
|
|
||||||
const { spaceTitle, section, withVariants, withSections, withSiteVariants } = props;
|
|
||||||
const [state, setSearchState] = useSearch();
|
|
||||||
const language = useLanguage();
|
|
||||||
|
|
||||||
if (!state) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whether to include all variants in the search
|
|
||||||
const scopeIsExtended = ['extended', 'all'].includes(state.scope);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{withSections ? (
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<SegmentedControl className="grow animate-scale-in">
|
|
||||||
<SegmentedControlItem
|
|
||||||
active={['default', 'all'].includes(state.scope)}
|
|
||||||
label={tString(language, 'search_scope_all')}
|
|
||||||
icon="infinity"
|
|
||||||
onClick={() =>
|
|
||||||
setSearchState({
|
|
||||||
...state,
|
|
||||||
scope: scopeIsExtended ? 'all' : 'default',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SegmentedControlItem
|
|
||||||
active={['current', 'extended'].includes(state.scope)}
|
|
||||||
icon={section?.icon}
|
|
||||||
label={tString(language, 'search_scope_current', section?.title)}
|
|
||||||
onClick={() =>
|
|
||||||
setSearchState({
|
|
||||||
...state,
|
|
||||||
scope: scopeIsExtended ? 'extended' : 'current',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SegmentedControl>
|
|
||||||
{withSiteVariants ? (
|
|
||||||
<DropdownMenu
|
|
||||||
button={
|
|
||||||
<Button
|
|
||||||
icon="ellipsis"
|
|
||||||
iconOnly
|
|
||||||
size="default"
|
|
||||||
variant="blank"
|
|
||||||
label={tString(language, 'more')}
|
|
||||||
className="shrink-0"
|
|
||||||
active={scopeIsExtended}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
setSearchState({
|
|
||||||
...state,
|
|
||||||
scope: (() => {
|
|
||||||
switch (state.scope) {
|
|
||||||
case 'extended':
|
|
||||||
return 'current';
|
|
||||||
case 'current':
|
|
||||||
return 'extended';
|
|
||||||
case 'all':
|
|
||||||
return 'default';
|
|
||||||
default:
|
|
||||||
return 'all';
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
active={scopeIsExtended}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Checkbox checked={scopeIsExtended} />
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
Include all variants
|
|
||||||
<div className="text-tint-subtle text-xs">
|
|
||||||
Search content in every language/version
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : withVariants ? (
|
|
||||||
<SegmentedControl className="mb-2 animate-scale-in">
|
|
||||||
<SegmentedControlItem
|
|
||||||
size={withSections ? 'small' : 'medium'}
|
|
||||||
active={!scopeIsExtended}
|
|
||||||
className="py-1"
|
|
||||||
label={tString(language, 'search_scope_current', spaceTitle)}
|
|
||||||
onClick={() =>
|
|
||||||
setSearchState({
|
|
||||||
...state,
|
|
||||||
scope: 'default',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SegmentedControlItem
|
|
||||||
size={withSections ? 'small' : 'medium'}
|
|
||||||
active={scopeIsExtended}
|
|
||||||
className="py-1"
|
|
||||||
icon="infinity"
|
|
||||||
label={tString(language, 'search_scope_all')}
|
|
||||||
onClick={() => setSearchState({ ...state, scope: 'all' })}
|
|
||||||
/>
|
|
||||||
</SegmentedControl>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
|
|
||||||
import { type Assistant, useAI } from '@/components/AI';
|
import { type Assistant, useAI } from '@/components/AI';
|
||||||
import { useTrackEvent } from '../Insights';
|
import { useTrackEvent } from '../Insights';
|
||||||
|
import { isQuestion } from './isQuestion';
|
||||||
import type { SearchScope } from './useSearch';
|
import type { SearchScope } from './useSearch';
|
||||||
|
|
||||||
export type ResultType =
|
export type ResultType =
|
||||||
@@ -43,7 +44,8 @@ export function useSearchResults(props: {
|
|||||||
const [resultsState, setResultsState] = React.useState<{
|
const [resultsState, setResultsState] = React.useState<{
|
||||||
results: ResultType[];
|
results: ResultType[];
|
||||||
fetching: boolean;
|
fetching: boolean;
|
||||||
}>({ results: [], fetching: false });
|
error: boolean;
|
||||||
|
}>({ results: [], fetching: false, error: false });
|
||||||
|
|
||||||
const { assistants } = useAI();
|
const { assistants } = useAI();
|
||||||
const withAI = assistants.length > 0;
|
const withAI = assistants.length > 0;
|
||||||
@@ -54,7 +56,7 @@ export function useSearchResults(props: {
|
|||||||
}
|
}
|
||||||
if (!query) {
|
if (!query) {
|
||||||
if (!withAI) {
|
if (!withAI) {
|
||||||
setResultsState({ results: [], fetching: false });
|
setResultsState({ results: [], fetching: false, error: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,11 +66,11 @@ export function useSearchResults(props: {
|
|||||||
results,
|
results,
|
||||||
`Cached recommended questions should be set for site-space ${siteSpaceId}`
|
`Cached recommended questions should be set for site-space ${siteSpaceId}`
|
||||||
);
|
);
|
||||||
setResultsState({ results, fetching: false });
|
setResultsState({ results, fetching: false, error: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setResultsState({ results: [], fetching: false });
|
setResultsState({ results: [], fetching: false, error: false });
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -102,7 +104,11 @@ export function useSearchResults(props: {
|
|||||||
cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions);
|
cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions);
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setResultsState({ results: [...recommendedQuestions], fetching: false });
|
setResultsState({
|
||||||
|
results: [...recommendedQuestions],
|
||||||
|
fetching: false,
|
||||||
|
error: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -112,44 +118,55 @@ export function useSearchResults(props: {
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
setResultsState((prev) => ({ results: prev.results, fetching: true }));
|
setResultsState((prev) => ({ results: prev.results, fetching: true, error: false }));
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const timeout = setTimeout(async () => {
|
const timeout = setTimeout(async () => {
|
||||||
const results = await (() => {
|
try {
|
||||||
if (scope === 'all') {
|
const results = await (() => {
|
||||||
// Search all content on the site
|
if (scope === 'all') {
|
||||||
return searchAllSiteContent(query);
|
// Search all content on the site
|
||||||
}
|
return searchAllSiteContent(query);
|
||||||
if (scope === 'default') {
|
}
|
||||||
// Search the current section's variant + matched/default variant for other sections
|
if (scope === 'default') {
|
||||||
return searchCurrentSiteSpaceContent(query, siteSpaceId);
|
// Search the current section's variant + matched/default variant for other sections
|
||||||
}
|
return searchCurrentSiteSpaceContent(query, siteSpaceId);
|
||||||
if (scope === 'extended') {
|
}
|
||||||
// Search all variants of the current section
|
if (scope === 'extended') {
|
||||||
return searchSpecificSiteSpaceContent(query, siteSpaceIds);
|
// Search all variants of the current section
|
||||||
}
|
return searchSpecificSiteSpaceContent(query, siteSpaceIds);
|
||||||
if (scope === 'current') {
|
}
|
||||||
// Search only the current section's current variant
|
if (scope === 'current') {
|
||||||
return searchSpecificSiteSpaceContent(query, [siteSpaceId]);
|
// Search only the current section's current variant
|
||||||
}
|
return searchSpecificSiteSpaceContent(query, [siteSpaceId]);
|
||||||
throw new Error(`Unhandled search scope: ${scope}`);
|
}
|
||||||
})();
|
throw new Error(`Unhandled search scope: ${scope}`);
|
||||||
|
})();
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!results) {
|
||||||
|
// One time when this one returns undefined is when it cannot find the server action and returns the html from the page.
|
||||||
|
// In that case, we want to avoid being stuck in a loading state, but it is an error.
|
||||||
|
// We could potentially try to force reload the page here, but i'm not 100% sure it would be a better experience.
|
||||||
|
setResultsState({ results: [], fetching: false, error: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setResultsState({ results, fetching: false, error: false });
|
||||||
|
|
||||||
|
trackEvent({
|
||||||
|
type: 'search_type_query',
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// If there is an error, we need to catch it to avoid infinite loading state.
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResultsState({ results: [], fetching: false, error: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!results) {
|
|
||||||
setResultsState({ results: [], fetching: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setResultsState({ results, fetching: false });
|
|
||||||
|
|
||||||
trackEvent({
|
|
||||||
type: 'search_type_query',
|
|
||||||
query,
|
|
||||||
});
|
|
||||||
}, 350);
|
}, 350);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -182,13 +199,16 @@ function withAskTriggers(
|
|||||||
return without;
|
return without;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queryIsQuestion = isQuestion(query);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
...(queryIsQuestion ? [] : (without ?? [])),
|
||||||
...assistants.map((assistant, index) => ({
|
...assistants.map((assistant, index) => ({
|
||||||
type: 'question' as const,
|
type: 'question' as const,
|
||||||
id: `question-${index}`,
|
id: `question-${index}`,
|
||||||
query,
|
query,
|
||||||
assistant,
|
assistant,
|
||||||
})),
|
})),
|
||||||
...(without ?? []),
|
...(!queryIsQuestion ? [] : (without ?? [])),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { PageAside } from '@/components/PageAside';
|
import { PageAside } from '@/components/PageAside';
|
||||||
import { PageBody, PageCover } from '@/components/PageBody';
|
import { PageBody, PageCover } from '@/components/PageBody';
|
||||||
@@ -75,9 +74,7 @@ export async function SitePage(props: SitePageProps) {
|
|||||||
insightsDisplayContext={SiteInsightsDisplayContext.Site}
|
insightsDisplayContext={SiteInsightsDisplayContext.Site}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<React.Suspense fallback={null}>
|
<PageClientLayout />
|
||||||
<PageClientLayout />
|
|
||||||
</React.Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
</PageContextProvider>
|
</PageContextProvider>
|
||||||
);
|
);
|
||||||
@@ -102,6 +99,9 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!pageTarget) {
|
if (!pageTarget) {
|
||||||
|
if (context.isFallback) {
|
||||||
|
redirect(context.linker.toPathInSpace('/'));
|
||||||
|
}
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +154,10 @@ export async function getSitePageData(props: SitePageProps) {
|
|||||||
// before trying to resolve the page again
|
// before trying to resolve the page again
|
||||||
redirect(context.linker.toPathInSpace(pathname));
|
redirect(context.linker.toPathInSpace(pathname));
|
||||||
} else {
|
} else {
|
||||||
|
// If the page is not found and we are in fallback mode, return a redirect to the basepath
|
||||||
|
if (context.isFallback) {
|
||||||
|
redirect(context.linker.toPathInSpace('/'));
|
||||||
|
}
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
} else if (getPagePath(context.revision.pages, pageTarget.page) !== rawPathname) {
|
} else if (getPagePath(context.revision.pages, pageTarget.page) !== rawPathname) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Icon, type IconName } from '@gitbook/icons';
|
import { Icon, type IconName } from '@gitbook/icons';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'motion/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||||
|
|||||||
@@ -67,15 +67,18 @@ export function SiteSectionTabs(props: {
|
|||||||
className={tcls(
|
className={tcls(
|
||||||
'grow',
|
'grow',
|
||||||
'md:-ml-8 -ml-4 sm:-ml-6',
|
'md:-ml-8 -ml-4 sm:-ml-6',
|
||||||
!children ? 'md:-mr-8 -mr-4 sm:-mr-6' : ''
|
!children
|
||||||
|
? 'md:-mr-8 -mr-4 sm:-mr-6'
|
||||||
|
: 'after:contents[] after:absolute after:inset-y-2 after:right-0 after:border-transparent after:border-r after:transition-colors'
|
||||||
)}
|
)}
|
||||||
activeId={currentSection.id}
|
activeId={currentSection.id}
|
||||||
|
trailingEdgeScrollClassName={children ? 'after:border-tint' : ''}
|
||||||
>
|
>
|
||||||
<NavigationMenu.List
|
<NavigationMenu.List
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'-mx-3 flex grow gap-2 bg-transparent',
|
'-mx-3 flex grow gap-2 bg-transparent',
|
||||||
'pl-4 sm:pl-6 md:pl-8',
|
'pl-4 sm:pl-6 md:pl-8',
|
||||||
!children ? 'pr-4 sm:pr-6 md:pr-8' : ''
|
!children ? 'pr-4 sm:pr-6 md:pr-8' : 'pr-4'
|
||||||
)}
|
)}
|
||||||
aria-label="Sections"
|
aria-label="Sections"
|
||||||
id="sections"
|
id="sections"
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { languages } from '@/intl/translations';
|
||||||
|
import { type SiteSpace, TranslationLanguage } from '@gitbook/api';
|
||||||
|
import { categorizeVariants } from './categorizeVariants';
|
||||||
|
|
||||||
|
type FakeSiteSpace = {
|
||||||
|
id: SiteSpace['id'];
|
||||||
|
title: SiteSpace['title'];
|
||||||
|
space: Pick<SiteSpace['space'], 'language'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeContext(current: FakeSiteSpace, all: FakeSiteSpace[]) {
|
||||||
|
return {
|
||||||
|
// Only the properties used by categorizeVariants are required for these tests
|
||||||
|
siteSpace: current,
|
||||||
|
siteSpaces: all,
|
||||||
|
} as unknown as Parameters<typeof categorizeVariants>[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const englishA = {
|
||||||
|
id: 'en-a',
|
||||||
|
title: 'Docs EN A',
|
||||||
|
space: { language: TranslationLanguage.En },
|
||||||
|
};
|
||||||
|
const englishB = {
|
||||||
|
id: 'en-b',
|
||||||
|
title: 'Docs EN B',
|
||||||
|
space: { language: TranslationLanguage.En },
|
||||||
|
};
|
||||||
|
const frenchA = {
|
||||||
|
id: 'fr-a',
|
||||||
|
title: 'Docs FR A',
|
||||||
|
space: { language: TranslationLanguage.Fr },
|
||||||
|
};
|
||||||
|
const frenchB = {
|
||||||
|
id: 'fr-b',
|
||||||
|
title: 'Docs FR B',
|
||||||
|
space: { language: TranslationLanguage.Fr },
|
||||||
|
};
|
||||||
|
const undefinedLanguage = {
|
||||||
|
id: 'undefined',
|
||||||
|
title: 'Docs in Undefined Language',
|
||||||
|
space: { language: undefined },
|
||||||
|
};
|
||||||
|
const unsupportedLanguage = {
|
||||||
|
id: 'unsupported',
|
||||||
|
title: 'Docs in Unsupported Language',
|
||||||
|
space: { language: 'xx' as TranslationLanguage },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('categorizeVariants', () => {
|
||||||
|
it('returns all spaces as generic and no translations for single-language sites', () => {
|
||||||
|
const ctx = makeContext(englishA, [englishA, englishB]);
|
||||||
|
|
||||||
|
const result = categorizeVariants(ctx);
|
||||||
|
|
||||||
|
expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b']);
|
||||||
|
expect(result.translations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all spaces as generic and no translations for sites with 1 language and an undefined language', () => {
|
||||||
|
const ctx = makeContext(englishA, [englishA, englishB, undefinedLanguage]);
|
||||||
|
|
||||||
|
const result = categorizeVariants(ctx);
|
||||||
|
|
||||||
|
expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b', 'undefined']);
|
||||||
|
expect(result.translations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps one-per-language translations without remapping titles', () => {
|
||||||
|
const ctx = makeContext(englishA, [englishA, frenchA]);
|
||||||
|
|
||||||
|
const result = categorizeVariants(ctx);
|
||||||
|
|
||||||
|
// Generic should only include current language variants when multi-language
|
||||||
|
expect(result.generic.map((s) => s.id)).toEqual(['en-a']);
|
||||||
|
|
||||||
|
// With exactly 1 per language, translations length equals number of languages → no remap
|
||||||
|
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
|
||||||
|
{ id: 'en-a', title: 'Docs EN A' },
|
||||||
|
{ id: 'fr-a', title: 'Docs FR A' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps one-per-language translations without remapping titles, including unsupported languages', () => {
|
||||||
|
const ctx = makeContext(englishA, [englishA, unsupportedLanguage]);
|
||||||
|
|
||||||
|
const result = categorizeVariants(ctx);
|
||||||
|
|
||||||
|
// Generic should only include current language variants when multi-language
|
||||||
|
expect(result.generic.map((s) => s.id)).toEqual(['en-a']);
|
||||||
|
|
||||||
|
// With exactly 1 per language, translations length equals number of languages → no remap
|
||||||
|
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
|
||||||
|
{ id: 'en-a', title: 'Docs EN A' },
|
||||||
|
{ id: 'unsupported', title: 'Docs in Unsupported Language' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps one-per-language translations when there are more than 1 language and an undefined language', () => {
|
||||||
|
const ctx = makeContext(englishA, [englishA, frenchA, undefinedLanguage]);
|
||||||
|
|
||||||
|
const result = categorizeVariants(ctx);
|
||||||
|
|
||||||
|
expect(result.generic.map((s) => s.id)).toEqual(['en-a']);
|
||||||
|
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
|
||||||
|
{ id: 'en-a', title: 'Docs EN A' },
|
||||||
|
{ id: 'fr-a', title: 'Docs FR A' },
|
||||||
|
{ id: 'undefined', title: 'Docs in Undefined Language' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates to first space per language and maps titles to language names', () => {
|
||||||
|
const ctx = makeContext(englishA, [englishA, englishB, frenchA, frenchB]);
|
||||||
|
|
||||||
|
const result = categorizeVariants(ctx);
|
||||||
|
|
||||||
|
// Generic includes all current-language variants when multi-language
|
||||||
|
expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b']);
|
||||||
|
|
||||||
|
// Distinct languages are ['en','fr'] but initial translations had 4 → remap
|
||||||
|
// After remap: first per language, with title set to language label
|
||||||
|
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
|
||||||
|
{ id: 'en-a', title: languages.en.language },
|
||||||
|
{ id: 'fr-a', title: languages.fr.language },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates to first space per language and maps titles to language names, and falls back to original title if no language is found', () => {
|
||||||
|
const ctx = makeContext(englishA, [
|
||||||
|
englishA,
|
||||||
|
englishB,
|
||||||
|
frenchA,
|
||||||
|
frenchB,
|
||||||
|
undefinedLanguage,
|
||||||
|
unsupportedLanguage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = categorizeVariants(ctx);
|
||||||
|
|
||||||
|
expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b']);
|
||||||
|
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
|
||||||
|
{ id: 'en-a', title: languages.en.language },
|
||||||
|
{ id: 'fr-a', title: languages.fr.language },
|
||||||
|
{ id: 'undefined', title: 'Docs in Undefined Language' },
|
||||||
|
{ id: 'unsupported', title: 'Docs in Unsupported Language' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,11 +10,9 @@ import { Footer } from '@/components/Footer';
|
|||||||
import { Header, HeaderLogo } from '@/components/Header';
|
import { Header, HeaderLogo } from '@/components/Header';
|
||||||
import { TableOfContents } from '@/components/TableOfContents';
|
import { TableOfContents } from '@/components/TableOfContents';
|
||||||
import { CONTAINER_STYLE } from '@/components/layout';
|
import { CONTAINER_STYLE } from '@/components/layout';
|
||||||
import { tcls } from '@/lib/tailwind';
|
|
||||||
|
|
||||||
import { getSpaceLanguage } from '@/intl/server';
|
|
||||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||||
import { GITBOOK_APP_URL } from '@/lib/env';
|
import { GITBOOK_APP_URL } from '@/lib/env';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { AIChatProvider } from '../AI';
|
import { AIChatProvider } from '../AI';
|
||||||
import type { RenderAIMessageOptions } from '../AI';
|
import type { RenderAIMessageOptions } from '../AI';
|
||||||
import { AIChat } from '../AIChat';
|
import { AIChat } from '../AIChat';
|
||||||
@@ -27,6 +25,7 @@ import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
|||||||
import { CurrentContentProvider } from '../hooks';
|
import { CurrentContentProvider } from '../hooks';
|
||||||
import { NavigationLoader } from '../primitives/NavigationLoader';
|
import { NavigationLoader } from '../primitives/NavigationLoader';
|
||||||
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
|
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
|
||||||
|
import { categorizeVariants } from './categorizeVariants';
|
||||||
|
|
||||||
type SpaceLayoutProps = {
|
type SpaceLayoutProps = {
|
||||||
context: GitBookSiteContext;
|
context: GitBookSiteContext;
|
||||||
@@ -105,16 +104,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
|||||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||||
|
|
||||||
const withSections = Boolean(sections && sections.list.length > 1);
|
const withSections = Boolean(sections && sections.list.length > 1);
|
||||||
|
const variants = categorizeVariants(context);
|
||||||
const currentLanguage = getSpaceLanguage(context);
|
|
||||||
const withVariants: 'generic' | 'translations' | undefined =
|
|
||||||
siteSpaces.length > 1
|
|
||||||
? siteSpaces.some(
|
|
||||||
(space) => space.space.language && space.space.language !== currentLanguage.locale
|
|
||||||
)
|
|
||||||
? 'translations'
|
|
||||||
: 'generic'
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const withFooter =
|
const withFooter =
|
||||||
customization.themes.toggeable ||
|
customization.themes.toggeable ||
|
||||||
@@ -125,7 +115,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
|||||||
return (
|
return (
|
||||||
<SpaceLayoutServerContext {...props}>
|
<SpaceLayoutServerContext {...props}>
|
||||||
<Announcement context={context} />
|
<Announcement context={context} />
|
||||||
<Header withTopHeader={withTopHeader} withVariants={withVariants} context={context} />
|
<Header withTopHeader={withTopHeader} variants={variants} context={context} />
|
||||||
<NavigationLoader />
|
<NavigationLoader />
|
||||||
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
|
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
|
||||||
<AIChat trademark={customization.trademark.enabled} />
|
<AIChat trademark={customization.trademark.enabled} />
|
||||||
@@ -165,11 +155,15 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HeaderLogo context={context} />
|
<HeaderLogo context={context} />
|
||||||
{withVariants === 'translations' ? (
|
{variants.translations.length > 1 ? (
|
||||||
<TranslationsDropdown
|
<TranslationsDropdown
|
||||||
context={context}
|
context={context}
|
||||||
siteSpace={siteSpace}
|
siteSpace={
|
||||||
siteSpaces={siteSpaces}
|
variants.translations.find(
|
||||||
|
(space) => space.id === siteSpace.id
|
||||||
|
) ?? siteSpace
|
||||||
|
}
|
||||||
|
siteSpaces={variants.translations}
|
||||||
className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden"
|
className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -183,7 +177,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<SearchContainer
|
<SearchContainer
|
||||||
style={CustomizationSearchStyle.Subtle}
|
style={CustomizationSearchStyle.Subtle}
|
||||||
withVariants={withVariants === 'generic'}
|
withVariants={variants.generic.length > 1}
|
||||||
withSiteVariants={
|
withSiteVariants={
|
||||||
sections?.list.some(
|
sections?.list.some(
|
||||||
(s) =>
|
(s) =>
|
||||||
@@ -213,14 +207,14 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
|||||||
sections={encodeClientSiteSections(context, sections)}
|
sections={encodeClientSiteSections(context, sections)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{withVariants === 'generic' && (
|
{variants.generic.length > 1 ? (
|
||||||
<SpacesDropdown
|
<SpacesDropdown
|
||||||
context={context}
|
context={context}
|
||||||
siteSpace={siteSpace}
|
siteSpace={siteSpace}
|
||||||
siteSpaces={siteSpaces}
|
siteSpaces={variants.generic}
|
||||||
className="w-full px-3 py-2"
|
className="w-full px-3 py-2"
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { languages } from '@/intl/translations';
|
||||||
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorize the variants of the space into generic and translation variants.
|
||||||
|
*/
|
||||||
|
export function categorizeVariants(context: GitBookSiteContext) {
|
||||||
|
const { siteSpace, siteSpaces } = context;
|
||||||
|
const currentLanguage = siteSpace.space.language;
|
||||||
|
|
||||||
|
// Get all languages of the variants.
|
||||||
|
const variantLanguages = [...new Set(siteSpaces.map((space) => space.space.language))];
|
||||||
|
|
||||||
|
// We only show the language picker if there are at least 2 distinct languages, excluding undefined.
|
||||||
|
const isMultiLanguage =
|
||||||
|
variantLanguages.filter((language) => language !== undefined).length > 1;
|
||||||
|
|
||||||
|
// Generic variants are all spaces that have the same language as the current (can also be undefined).
|
||||||
|
const genericVariants = isMultiLanguage
|
||||||
|
? siteSpaces.filter(
|
||||||
|
(space) => space === siteSpace || space.space.language === currentLanguage
|
||||||
|
)
|
||||||
|
: siteSpaces;
|
||||||
|
|
||||||
|
// Translation variants are all spaces that have a different language than the current.
|
||||||
|
let translationVariants = isMultiLanguage
|
||||||
|
? siteSpaces.filter(
|
||||||
|
(space) => space === siteSpace || space.space.language !== currentLanguage
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// If there is exactly 1 variant per language, we will use them as-is.
|
||||||
|
// Otherwise, we will create a translation dropdown with the first space of each language.
|
||||||
|
if (variantLanguages.length !== translationVariants.length) {
|
||||||
|
translationVariants = variantLanguages
|
||||||
|
// Get the first space of each language.
|
||||||
|
.map((variantLanguage) =>
|
||||||
|
translationVariants.find((space) => space.space.language === variantLanguage)
|
||||||
|
)
|
||||||
|
// Filter out unmatched languages.
|
||||||
|
.filter((space) => space !== undefined)
|
||||||
|
// Transform the title to include the language name if we have a translation. Otherwise, use the original title.
|
||||||
|
.map((space) => {
|
||||||
|
const language = languages[space.space.language as keyof typeof languages];
|
||||||
|
return {
|
||||||
|
...space,
|
||||||
|
title: language ? language.language : space.title,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
generic: genericVariants,
|
||||||
|
translations: translationVariants,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,14 +7,14 @@ import { tcls } from '@/lib/tailwind';
|
|||||||
import { PagesList } from './PagesList';
|
import { PagesList } from './PagesList';
|
||||||
import { TOCPageIcon } from './TOCPageIcon';
|
import { TOCPageIcon } from './TOCPageIcon';
|
||||||
|
|
||||||
export function PageGroupItem(props: { page: ClientTOCPageGroup }) {
|
export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boolean }) {
|
||||||
const { page } = props;
|
const { page, isFirst } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="group/page-group-item flex flex-col">
|
<li className="flex flex-col">
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'-top-6 group-first/page-group-item:-mt-6 sticky z-1 flex items-center gap-3 px-3 pt-6',
|
'-top-6 sticky z-1 flex items-center gap-3 px-3 pt-6',
|
||||||
'font-semibold text-xs uppercase tracking-wide',
|
'font-semibold text-xs uppercase tracking-wide',
|
||||||
'pb-3', // Add extra padding to make the header fade a bit nicer
|
'pb-3', // Add extra padding to make the header fade a bit nicer
|
||||||
'-mb-1.5', // Then pull the page items a bit closer, effective bottom padding is 1.5 units / 6px.
|
'-mb-1.5', // Then pull the page items a bit closer, effective bottom padding is 1.5 units / 6px.
|
||||||
@@ -26,7 +26,8 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup }) {
|
|||||||
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
|
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
|
||||||
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
|
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
|
||||||
'[html.sidebar-default.theme-gradient_&]:bg-gradient-primary',
|
'[html.sidebar-default.theme-gradient_&]:bg-gradient-primary',
|
||||||
'[html.sidebar-default.theme-gradient.tint_&]:bg-gradient-tint'
|
'[html.sidebar-default.theme-gradient.tint_&]:bg-gradient-tint',
|
||||||
|
isFirst ? '-mt-6' : ''
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TOCPageIcon page={page} />
|
<TOCPageIcon page={page} />
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ import { PageDocumentItem } from './PageDocumentItem';
|
|||||||
import { PageGroupItem } from './PageGroupItem';
|
import { PageGroupItem } from './PageGroupItem';
|
||||||
import { PageLinkItem } from './PageLinkItem';
|
import { PageLinkItem } from './PageLinkItem';
|
||||||
|
|
||||||
export function PagesList(props: { pages: ClientTOCPage[]; style?: ClassValue }) {
|
export function PagesList(props: {
|
||||||
const { pages, style } = props;
|
pages: ClientTOCPage[];
|
||||||
|
style?: ClassValue;
|
||||||
|
isRoot?: boolean;
|
||||||
|
}) {
|
||||||
|
const { pages, style, isRoot = false } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={tcls('flex flex-col gap-y-0.5', style)}>
|
<ul className={tcls('flex flex-col gap-y-0.5', style)}>
|
||||||
{pages.map((page) => {
|
{pages.map((page, idx) => {
|
||||||
switch (page.type) {
|
switch (page.type) {
|
||||||
case 'document':
|
case 'document':
|
||||||
return <PageDocumentItem key={page.id} page={page} />;
|
return <PageDocumentItem key={page.id} page={page} />;
|
||||||
@@ -23,7 +27,13 @@ export function PagesList(props: { pages: ClientTOCPage[]; style?: ClassValue })
|
|||||||
return <PageLinkItem key={page.id} page={page} />;
|
return <PageLinkItem key={page.id} page={page} />;
|
||||||
|
|
||||||
case 'group':
|
case 'group':
|
||||||
return <PageGroupItem key={page.id} page={page} />;
|
return (
|
||||||
|
<PageGroupItem
|
||||||
|
key={page.id}
|
||||||
|
page={page}
|
||||||
|
isFirst={isRoot && idx === 0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
assertNever(page);
|
assertNever(page);
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const TOC_ITEM_OFFSET = 100;
|
|||||||
* Scrolls the table of contents container to the page item when it's initially active.
|
* Scrolls the table of contents container to the page item when it's initially active.
|
||||||
*/
|
*/
|
||||||
export function useScrollToActiveTOCItem(props: {
|
export function useScrollToActiveTOCItem(props: {
|
||||||
anchorRef: React.RefObject<HTMLAnchorElement>;
|
anchorRef: React.RefObject<HTMLAnchorElement | null>;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { isActive, anchorRef } = props;
|
const { isActive, anchorRef } = props;
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export async function TableOfContents(props: {
|
|||||||
>
|
>
|
||||||
<PagesList
|
<PagesList
|
||||||
pages={pages}
|
pages={pages}
|
||||||
|
isRoot={true}
|
||||||
style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l"
|
style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l"
|
||||||
/>
|
/>
|
||||||
{customization.trademark.enabled ? (
|
{customization.trademark.enabled ? (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import { motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
|
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
@@ -184,9 +184,6 @@ const show = {
|
|||||||
const hide = {
|
const hide = {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
transitionEnd: {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function Descendants(props: {
|
function Descendants(props: {
|
||||||
@@ -195,12 +192,17 @@ function Descendants(props: {
|
|||||||
}) {
|
}) {
|
||||||
const { isVisible, children } = props;
|
const { isVisible, children } = props;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<AnimatePresence>
|
||||||
className="overflow-hidden"
|
{isVisible ? (
|
||||||
animate={isVisible ? show : hide}
|
<motion.div
|
||||||
initial={isVisible ? show : hide}
|
initial={hide}
|
||||||
>
|
animate={show}
|
||||||
{children}
|
exit={hide}
|
||||||
</motion.div>
|
className="flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export * from './useCurrentPagePath';
|
|||||||
export * from './useCurrentContent';
|
export * from './useCurrentContent';
|
||||||
export * from './useCurrentPage';
|
export * from './useCurrentPage';
|
||||||
export * from './useNow';
|
export * from './useNow';
|
||||||
|
export * from './useListOverflow';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const HAS_INTERSECTION_OBSERVER = typeof IntersectionObserver !== 'undefined';
|
|||||||
* Return true if the element has been in the viewport once.
|
* Return true if the element has been in the viewport once.
|
||||||
*/
|
*/
|
||||||
export function useHasBeenInViewport(
|
export function useHasBeenInViewport(
|
||||||
containerRef: React.RefObject<HTMLElement>,
|
containerRef: React.RefObject<HTMLElement | null>,
|
||||||
options?: Pick<IntersectionObserverInit, 'root' | 'rootMargin' | 'threshold'>
|
options?: Pick<IntersectionObserverInit, 'root' | 'rootMargin' | 'threshold'>
|
||||||
) {
|
) {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ ch
|
|||||||
|
|
||||||
// Cleanup timeout on unmount
|
// Cleanup timeout on unmount
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
// Initialize hash on mount - It could be null on SSR rehydration
|
||||||
|
setHash(getHash());
|
||||||
return () => {
|
return () => {
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
@@ -61,7 +63,7 @@ export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ ch
|
|||||||
const onNavigationClick = React.useCallback((href: string) => {
|
const onNavigationClick = React.useCallback((href: string) => {
|
||||||
// We need to skip it for search like params (i.e. ?ask= or ?q=) because they don't really trigger a navigation
|
// We need to skip it for search like params (i.e. ?ask= or ?q=) because they don't really trigger a navigation
|
||||||
// Search may trigger a navigation whenn clicking on the ask ai for example, this is not something we want to track here
|
// Search may trigger a navigation whenn clicking on the ask ai for example, this is not something we want to track here
|
||||||
if (href.startsWith('?') || href.startsWith('#')) {
|
if (href.startsWith('?')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const url = new URL(
|
const url = new URL(
|
||||||
@@ -73,7 +75,8 @@ export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ ch
|
|||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
}
|
}
|
||||||
if (pathnameRef.current !== url.pathname) {
|
// We don't want to set isNavigating for same page hash navigation
|
||||||
|
if (pathnameRef.current !== url.pathname && !href.startsWith('#')) {
|
||||||
timeoutRef.current = window.setTimeout(() => {
|
timeoutRef.current = window.setTimeout(() => {
|
||||||
setIsNavigating(true);
|
setIsNavigating(true);
|
||||||
timeoutRef.current = null;
|
timeoutRef.current = null;
|
||||||
@@ -94,7 +97,7 @@ export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ ch
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to get the current hash from the URL.
|
* Hook to get the current hash from the URL. The hash is set on navigation clicks **NOT** on hashchange events or on navigation end.
|
||||||
* @see https://github.com/vercel/next.js/discussions/49465
|
* @see https://github.com/vercel/next.js/discussions/49465
|
||||||
* We use a different hack than this one, because for same page link it don't work
|
* We use a different hack than this one, because for same page link it don't work
|
||||||
* We can't use the `hashChange` event because it doesn't fire for `replaceState` and `pushState` which are used by Next.js.
|
* We can't use the `hashChange` event because it doesn't fire for `replaceState` and `pushState` which are used by Next.js.
|
||||||
@@ -102,7 +105,6 @@ export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ ch
|
|||||||
*/
|
*/
|
||||||
export function useHash() {
|
export function useHash() {
|
||||||
const { hash } = React.useContext(NavigationStatusContext);
|
const { hash } = React.useContext(NavigationStatusContext);
|
||||||
|
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const HAS_INTERSECTION_OBSERVER = typeof IntersectionObserver !== 'undefined';
|
|||||||
* Watch an element to know when it is in the viewport.
|
* Watch an element to know when it is in the viewport.
|
||||||
*/
|
*/
|
||||||
export function useInViewportListener(
|
export function useInViewportListener(
|
||||||
containerRef: React.RefObject<HTMLElement>,
|
containerRef: React.RefObject<HTMLElement | null>,
|
||||||
listener: (isIntersecting: boolean, disconnect: () => void) => void,
|
listener: (isIntersecting: boolean, disconnect: () => void) => void,
|
||||||
options?: Pick<IntersectionObserverInit, 'root' | 'rootMargin' | 'threshold'>
|
options?: Pick<IntersectionObserverInit, 'root' | 'rootMargin' | 'threshold'>
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
'use client';
|
||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface OverflowState {
|
||||||
|
/**
|
||||||
|
* Ref for the container element.
|
||||||
|
*/
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
/**
|
||||||
|
* Ref callback for each item in the list.
|
||||||
|
*/
|
||||||
|
itemRef: (element: HTMLElement | null) => void;
|
||||||
|
/**
|
||||||
|
* Set of IDs that are currently overflowing.
|
||||||
|
*/
|
||||||
|
overflowing: Set<string>;
|
||||||
|
/**
|
||||||
|
* Indicates if we are currently measuring the list.
|
||||||
|
*/
|
||||||
|
isMeasuring: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects which items are overflowing in a horizontal list.
|
||||||
|
* The items must have unique IDs set on their elements.
|
||||||
|
*
|
||||||
|
* In the measuring phase indicated by `isMeasuring`, all items must be rendered.
|
||||||
|
*/
|
||||||
|
export function useListOverflow(): OverflowState {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [overflowing, setOverflowing] = useState<Set<string>>(new Set());
|
||||||
|
const [isMeasuring, setIsMeasuring] = useState(false);
|
||||||
|
const itemRefs = useRef(new Map<string, HTMLElement>());
|
||||||
|
const rafRef = useRef(0);
|
||||||
|
|
||||||
|
const itemRef = useCallback((element: HTMLElement | null) => {
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
itemRefs.current.set(element.id, element);
|
||||||
|
return () => {
|
||||||
|
itemRefs.current.delete(element.id);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Measure on mount and when container size changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsMeasuring(true);
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
|
setIsMeasuring(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ro.observe(containerRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Measure which items are overflowing
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!containerRef.current || !isMeasuring) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
const newOverflowing = new Set<string>();
|
||||||
|
|
||||||
|
itemRefs.current.forEach((el, id) => {
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
if (elRect.right > containerRect.right + 1) {
|
||||||
|
newOverflowing.add(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setOverflowing((previous) => {
|
||||||
|
if (previous.size !== newOverflowing.size) {
|
||||||
|
return newOverflowing;
|
||||||
|
}
|
||||||
|
for (const id of previous) {
|
||||||
|
if (!newOverflowing.has(id)) {
|
||||||
|
return newOverflowing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return previous;
|
||||||
|
});
|
||||||
|
setIsMeasuring(false);
|
||||||
|
}, [isMeasuring]);
|
||||||
|
|
||||||
|
return { containerRef, itemRef, overflowing, isMeasuring };
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user