diff --git a/.changeset/big-jeans-press.md b/.changeset/big-jeans-press.md new file mode 100644 index 000000000..72860338e --- /dev/null +++ b/.changeset/big-jeans-press.md @@ -0,0 +1,5 @@ +--- +'@gitbook/react-openapi': patch +--- + +Add heredoc support for cURL JSON body diff --git a/.changeset/breezy-nights-repair.md b/.changeset/breezy-nights-repair.md new file mode 100644 index 000000000..1f4aa422c --- /dev/null +++ b/.changeset/breezy-nights-repair.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix webframe height issue diff --git a/.changeset/busy-weeks-add.md b/.changeset/busy-weeks-add.md new file mode 100644 index 000000000..4d105b51d --- /dev/null +++ b/.changeset/busy-weeks-add.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add breadcrumbs to search results diff --git a/.changeset/cold-dancers-buy.md b/.changeset/cold-dancers-buy.md new file mode 100644 index 000000000..04e8cb5e1 --- /dev/null +++ b/.changeset/cold-dancers-buy.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +SearchInput fixes — Translate Clear button, Bold theme color, "Esc" keyboard shortcut diff --git a/.changeset/curvy-forks-joke.md b/.changeset/curvy-forks-joke.md new file mode 100644 index 000000000..03de176c9 --- /dev/null +++ b/.changeset/curvy-forks-joke.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix search container shadow diff --git a/.changeset/early-cobras-raise.md b/.changeset/early-cobras-raise.md new file mode 100644 index 000000000..01eedec3e --- /dev/null +++ b/.changeset/early-cobras-raise.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Update dropdown menu styling diff --git a/.changeset/frank-doors-smell.md b/.changeset/frank-doors-smell.md new file mode 100644 index 000000000..399dc0236 --- /dev/null +++ b/.changeset/frank-doors-smell.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Printable collapsed code block diff --git a/.changeset/fruity-banks-doubt.md b/.changeset/fruity-banks-doubt.md new file mode 100644 index 000000000..82be5cb98 --- /dev/null +++ b/.changeset/fruity-banks-doubt.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Revert contentref anchor title diff --git a/.changeset/hot-suits-wear.md b/.changeset/hot-suits-wear.md new file mode 100644 index 000000000..75c37046e --- /dev/null +++ b/.changeset/hot-suits-wear.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Reword "Everywhere" search scope to "All docs" diff --git a/.changeset/lazy-snails-warn.md b/.changeset/lazy-snails-warn.md new file mode 100644 index 000000000..e2467abfe --- /dev/null +++ b/.changeset/lazy-snails-warn.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Move the "Ask " to the bottom of search results if query is not a question diff --git a/.changeset/metal-apples-rule.md b/.changeset/metal-apples-rule.md new file mode 100644 index 000000000..fc499c8b5 --- /dev/null +++ b/.changeset/metal-apples-rule.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Improve AI Search branding diff --git a/.changeset/proud-kings-know.md b/.changeset/proud-kings-know.md new file mode 100644 index 000000000..77cefd0df --- /dev/null +++ b/.changeset/proud-kings-know.md @@ -0,0 +1,6 @@ +--- +'@gitbook/react-openapi': patch +'gitbook': patch +--- + +Add alt text support to card covers diff --git a/.changeset/puny-suits-boil.md b/.changeset/puny-suits-boil.md new file mode 100644 index 000000000..251bc1d6d --- /dev/null +++ b/.changeset/puny-suits-boil.md @@ -0,0 +1,5 @@ +--- +'gitbook': patch +--- + +Improve ancestor label in references diff --git a/.changeset/quiet-comics-act.md b/.changeset/quiet-comics-act.md new file mode 100644 index 000000000..b3ed43a64 --- /dev/null +++ b/.changeset/quiet-comics-act.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Tweak language dropdown styling diff --git a/.changeset/quiet-icons-deny.md b/.changeset/quiet-icons-deny.md new file mode 100644 index 000000000..9345f6b8f --- /dev/null +++ b/.changeset/quiet-icons-deny.md @@ -0,0 +1,5 @@ +--- +'gitbook': patch +--- + +Fix ToggeableLinkItem display diff --git a/.changeset/ready-parrots-travel.md b/.changeset/ready-parrots-travel.md new file mode 100644 index 000000000..e3c772721 --- /dev/null +++ b/.changeset/ready-parrots-travel.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix icon resolution for page group diff --git a/.changeset/rich-hairs-check.md b/.changeset/rich-hairs-check.md new file mode 100644 index 000000000..f12f76ba5 --- /dev/null +++ b/.changeset/rich-hairs-check.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Support translations and generic variants together diff --git a/.changeset/silver-nails-flow.md b/.changeset/silver-nails-flow.md new file mode 100644 index 000000000..67c41725b --- /dev/null +++ b/.changeset/silver-nails-flow.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Edgeless Assistant window on `depth: flat` sites diff --git a/.changeset/slick-candies-learn.md b/.changeset/slick-candies-learn.md new file mode 100644 index 000000000..6d1fce3bc --- /dev/null +++ b/.changeset/slick-candies-learn.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add confirmation to "Copy MCP URL" page action diff --git a/.changeset/slimy-pens-travel.md b/.changeset/slimy-pens-travel.md new file mode 100644 index 000000000..388467f75 --- /dev/null +++ b/.changeset/slimy-pens-travel.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Implement unique title tags for sections & variants diff --git a/.changeset/slow-boxes-go.md b/.changeset/slow-boxes-go.md new file mode 100644 index 000000000..1d16b3fbe --- /dev/null +++ b/.changeset/slow-boxes-go.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Treat pages without visible TOC items as `no-toc` diff --git a/.changeset/slow-numbers-dig.md b/.changeset/slow-numbers-dig.md new file mode 100644 index 000000000..392d2ba36 --- /dev/null +++ b/.changeset/slow-numbers-dig.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add padding to TOC page group items diff --git a/.changeset/smart-trains-beg.md b/.changeset/smart-trains-beg.md new file mode 100644 index 000000000..658165bed --- /dev/null +++ b/.changeset/smart-trains-beg.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Update search scope UI diff --git a/.changeset/solid-things-own.md b/.changeset/solid-things-own.md new file mode 100644 index 000000000..3f7263a42 --- /dev/null +++ b/.changeset/solid-things-own.md @@ -0,0 +1,5 @@ +--- +'gitbook': patch +--- + +Fix OpenAPI path display diff --git a/.changeset/sour-cobras-grab.md b/.changeset/sour-cobras-grab.md new file mode 100644 index 000000000..e66a5e50a --- /dev/null +++ b/.changeset/sour-cobras-grab.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Update site layout and blocks with container queries and better transitions diff --git a/.changeset/sunny-cobras-feel.md b/.changeset/sunny-cobras-feel.md new file mode 100644 index 000000000..6d94fc5e9 --- /dev/null +++ b/.changeset/sunny-cobras-feel.md @@ -0,0 +1,6 @@ +--- +'@gitbook/react-openapi': patch +'gitbook': patch +--- + +Improve OAuth2 scopes handling in OpenAPI diff --git a/.changeset/two-rocks-cry.md b/.changeset/two-rocks-cry.md new file mode 100644 index 000000000..680d1c5e0 --- /dev/null +++ b/.changeset/two-rocks-cry.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix opacity of ScrollContainer buttons on bold themes diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index f5a5d0c97..d5c6f5a21 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -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 ##### Prerequisites: -- Node.js (Version: >= 22.3) - - Use `nvm` for easy Node management -- [Bun](https://bun.sh/) (Version: >=1.2.15) - - We use a text-based lockfile which isn't supported below 1.2.15 + +- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`) +- [Bun](https://bun.sh/) (see "packageManager" in `package.json`) ##### Setup steps: diff --git a/bun.lock b/bun.lock index 50099ab0e..fa106bfce 100644 --- a/bun.lock +++ b/bun.lock @@ -343,7 +343,7 @@ "react-dom": "catalog:", }, "catalog": { - "@gitbook/api": "0.143.2", + "@gitbook/api": "0.147.0", "@scalar/api-client-react": "^1.3.46", "@tsconfig/node20": "^20.1.6", "@tsconfig/strictest": "^2.0.6", @@ -724,7 +724,7 @@ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="], - "@gitbook/api": ["@gitbook/api@0.143.2", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ZIQwIgX+artl0o20ftnzeJyM9icwQ6pFeM55AG/lEdeFbMuUGxTfSnZ90+8Aou8l/3ELR3/tOkU2SAvyKc+ATw=="], + "@gitbook/api": ["@gitbook/api@0.147.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ON32kqr9ID6td9h26bmmPflpoKZZ3+Z0W5URjc/YIDfk+YFb7/rVrOGzYf3NV9R+uZuxVzIKRJ9Yq8v0WLVwNQ=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], diff --git a/package.json b/package.json index 4d9be922b..bfea6c93e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,9 @@ { "name": "gitbook", "version": "0.1.0", + "engines": { + "node": "^22.3.0" + }, "devDependencies": { "@biomejs/biome": "^1.9.4", "@changesets/cli": "^2.29.7", @@ -39,7 +42,7 @@ "catalog": { "@tsconfig/strictest": "^2.0.6", "@tsconfig/node20": "^20.1.6", - "@gitbook/api": "0.143.2", + "@gitbook/api": "0.147.0", "@scalar/api-client-react": "^1.3.46", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/gitbook/e2e/customers.spec.ts b/packages/gitbook/e2e/customers.spec.ts index 3d79d0984..1988ae73f 100644 --- a/packages/gitbook/e2e/customers.spec.ts +++ b/packages/gitbook/e2e/customers.spec.ts @@ -112,11 +112,11 @@ const testCases: TestsCase[] = [ contentBaseURL: 'https://adiblar.gitbook.io', tests: [{ name: 'Home', url: '/' }], }, - { - name: 'docs.gradient.network', - contentBaseURL: 'https://docs.gradient.network', - tests: [{ name: 'Home', url: '/' }], - }, + // { + // name: 'docs.gradient.network', + // contentBaseURL: 'https://docs.gradient.network', + // tests: [{ name: 'Home', url: '/' }], + // }, // { // name: 'mygate-network.gitbook.io', // contentBaseURL: 'https://mygate-network.gitbook.io', @@ -172,11 +172,6 @@ const testCases: TestsCase[] = [ contentBaseURL: 'https://docs.realapp.link', tests: [{ name: 'Home', url: '/' }], }, - { - name: 'docs.plaza.finance', - contentBaseURL: 'https://docs.plaza.finance', - tests: [{ name: 'Home', url: '/' }], - }, { name: 'docs.publicai.io', contentBaseURL: 'https://docs.publicai.io', diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index f008550d4..8ea1a361a 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -33,6 +33,7 @@ import { headerLinks, runTestCases, waitForCookiesDialog, + waitForCoverImages, waitForNotFound, } from './util'; @@ -228,6 +229,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, { name: 'Not found', @@ -727,7 +780,10 @@ const testCases: TestsCase[] = [ url: 'blocks/integrations', run: async (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('Diagram', { exact: true })).toBeVisible(); }, @@ -851,7 +907,10 @@ const testCases: TestsCase[] = [ { name: 'With cover', url: 'page-options/page-with-cover', - run: waitForCookiesDialog, + run: async (page) => { + await waitForCookiesDialog(page); + await waitForCoverImages(page); + }, }, { name: 'With cover for dark mode', @@ -866,12 +925,18 @@ const testCases: TestsCase[] = [ { name: 'With hero cover', url: 'page-options/page-with-hero-cover', - run: waitForCookiesDialog, + run: async (page) => { + await waitForCookiesDialog(page); + await waitForCoverImages(page); + }, }, { name: 'With cover and no TOC', url: 'page-options/page-with-cover-and-no-toc', - run: waitForCookiesDialog, + run: async (page) => { + await waitForCookiesDialog(page); + await waitForCoverImages(page); + }, screenshot: { waitForTOCScrolling: false, }, diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts index c2b3a9300..6b62dea8c 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -154,6 +154,13 @@ export async function waitForNotFound(_page: Page, response: Response | null) { expect(response?.status()).toBe(404); } +export async function waitForCoverImages(page: Page) { + // Wait for cover images to exist (not the shimmer placeholder) + await expect(page.locator('img[alt="Page cover"]').first()).toBeVisible({ + timeout: 10_000, + }); +} + /** * Transform test cases into Playwright tests and run it. */ diff --git a/packages/gitbook/open-next.config.ts b/packages/gitbook/open-next.config.ts index 187230911..f4f5c25df 100644 --- a/packages/gitbook/open-next.config.ts +++ b/packages/gitbook/open-next.config.ts @@ -7,7 +7,8 @@ export default { converter: 'edge', proxyExternalRequest: 'fetch', 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), }, }, @@ -18,7 +19,8 @@ export default { converter: 'edge', proxyExternalRequest: 'fetch', 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), }, }, diff --git a/packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc b/packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc index 9ea03e2d2..22f8f0205 100644 --- a/packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc @@ -1,15 +1,13 @@ { "main": "default.js", "name": "gitbook-open-v2-server", + "keep_names": false, "compatibility_date": "2025-04-14", "compatibility_flags": [ "nodejs_compat", "allow_importable_env", "global_fetch_strictly_public" ], - "observability": { - "enabled": true - }, "vars": { "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true" }, diff --git a/packages/gitbook/openNext/customWorkers/doWrangler.jsonc b/packages/gitbook/openNext/customWorkers/doWrangler.jsonc index 0ea87bbf2..421c2e5c8 100644 --- a/packages/gitbook/openNext/customWorkers/doWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/doWrangler.jsonc @@ -2,14 +2,12 @@ "main": "do.js", "name": "gitbook-open-v2-do", "compatibility_date": "2025-04-14", + "keep_names": false, "compatibility_flags": [ "nodejs_compat", "allow_importable_env", "global_fetch_strictly_public" ], - "observability": { - "enabled": true - }, "env": { "preview": { "vars": { diff --git a/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc b/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc index 168686d43..990db7e22 100644 --- a/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc @@ -2,6 +2,7 @@ "main": "middleware.js", "name": "gitbook-open-v2", "compatibility_date": "2025-04-14", + "keep_names": false, "compatibility_flags": [ "nodejs_compat", "allow_importable_env", @@ -11,9 +12,6 @@ "directory": "../../.open-next/assets", "binding": "ASSETS" }, - "observability": { - "enabled": true - }, "vars": { "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true" }, diff --git a/packages/gitbook/openNext/incrementalCache.ts b/packages/gitbook/openNext/incrementalCache/incrementalCache.ts similarity index 90% rename from packages/gitbook/openNext/incrementalCache.ts rename to packages/gitbook/openNext/incrementalCache/incrementalCache.ts index 9fde1fc41..11c0f8977 100644 --- a/packages/gitbook/openNext/incrementalCache.ts +++ b/packages/gitbook/openNext/incrementalCache/incrementalCache.ts @@ -7,8 +7,6 @@ import type { } from '@opennextjs/aws/types/overrides.js'; import { getCloudflareContext } from '@opennextjs/cloudflare'; -import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache'; - import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types'; 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 * 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'; async get( @@ -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, -}); diff --git a/packages/gitbook/openNext/incrementalCache/middleware.ts b/packages/gitbook/openNext/incrementalCache/middleware.ts new file mode 100644 index 000000000..2204ae4c6 --- /dev/null +++ b/packages/gitbook/openNext/incrementalCache/middleware.ts @@ -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, +}); diff --git a/packages/gitbook/openNext/incrementalCache/server.ts b/packages/gitbook/openNext/incrementalCache/server.ts new file mode 100644 index 000000000..87c496325 --- /dev/null +++ b/packages/gitbook/openNext/incrementalCache/server.ts @@ -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, +}); diff --git a/packages/gitbook/src/components/AI/useAI.tsx b/packages/gitbook/src/components/AI/useAI.tsx index e3dbecc07..29df1f235 100644 --- a/packages/gitbook/src/components/AI/useAI.tsx +++ b/packages/gitbook/src/components/AI/useAI.tsx @@ -1,14 +1,14 @@ 'use client'; 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 type { ReactNode } from 'react'; import { tString, useLanguage } from '@/intl/client'; import type { GitBookAssistant } from '@gitbook/browser-types'; import { useAIChatController, useAIChatState } from '.'; -import { AIChatIcon, getAIChatName } from '../AIChat'; +import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat'; import { useIntegrationAssistants } from '../Integrations'; import { useSearch } from '../Search/useSearch'; @@ -104,16 +104,7 @@ export function useAI(): AIContext { assistants.push({ id: 'gitbook-ai-search', label: tString(language, 'ai_chat_context_badge'), - icon: ( -
- - -
- ), + icon: , open: (query?: string) => { if (query) { setSearchState((prev) => diff --git a/packages/gitbook/src/components/AIChat/AIChat.tsx b/packages/gitbook/src/components/AIChat/AIChat.tsx index 90484bfd5..8cdbe49c0 100644 --- a/packages/gitbook/src/components/AIChat/AIChat.tsx +++ b/packages/gitbook/src/components/AIChat/AIChat.tsx @@ -2,6 +2,7 @@ import { t, tString, useLanguage } from '@/intl/client'; import type { TranslationLanguage } from '@/intl/translations'; +import { tcls } from '@/lib/tailwind'; import { Icon } from '@gitbook/icons'; import React from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -65,16 +66,17 @@ export function AIChat(props: { trademark: boolean }) { } }, [chat.opened, trackEvent]); - if (!chat.opened) { - return null; - } - return (
- + diff --git a/packages/gitbook/src/components/AIChat/AIChatButton.tsx b/packages/gitbook/src/components/AIChat/AIChatButton.tsx index a990adbbd..abe996cec 100644 --- a/packages/gitbook/src/components/AIChat/AIChatButton.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatButton.tsx @@ -23,7 +23,7 @@ export function AIChatButton(props: { iconOnly={!showLabel} size="medium" variant="header" - className="h-9 px-2.5 max-md:[&_.button-content]:hidden" + className="h-9 px-2.5 @max-2xl:[&_.button-content]:hidden" label={
{t(language, 'ai_chat_ask', assistant.label)} diff --git a/packages/gitbook/src/components/AIChat/AIChatIcon.tsx b/packages/gitbook/src/components/AIChat/AIChatIcon.tsx index 6674eed5f..a4cf05154 100644 --- a/packages/gitbook/src/components/AIChat/AIChatIcon.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatIcon.tsx @@ -1,5 +1,5 @@ import { tcls } from '@/lib/tailwind'; -import { Icon } from '@gitbook/icons'; +import { Icon, IconStyle } from '@gitbook/icons'; import type React from 'react'; interface AIChatIconProps extends React.SVGProps { @@ -9,17 +9,18 @@ interface AIChatIconProps extends React.SVGProps { trademark?: boolean; } -export const AIChatIcon = ({ +export function AIChatIcon({ className = 'size-4', size, trademark = true, state = 'default', ...props -}: AIChatIconProps) => { +}: AIChatIconProps) { if (!trademark) { return ( ); -}; +} + +export function AISearchIcon({ + className = 'size-4', + state = 'default', +}: Pick) { + return ( +
+ + +
+ ); +} diff --git a/packages/gitbook/src/components/AIChat/AIChatMessages.tsx b/packages/gitbook/src/components/AIChat/AIChatMessages.tsx index 87a513321..700d31bcc 100644 --- a/packages/gitbook/src/components/AIChat/AIChatMessages.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatMessages.tsx @@ -81,11 +81,19 @@ export function AIChatMessages(props: { ); } -function HoldMessage() { +export function HoldMessage({ + breakLines = false, + className = '', +}: { breakLines?: boolean; className?: string }) { const language = useLanguage(); return ( -
+
{tString(language, 'ai_chat_hold_message_1') .split(' ') .map((word, index) => ( @@ -99,6 +107,7 @@ function HoldMessage() { {word}{' '} ))} + {breakLines ?
: null} {tString(language, 'ai_chat_hold_message_2') .split(' ') .map((word, index) => ( diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/ClientCodeBlock.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/ClientCodeBlock.tsx index 9d8cfbb39..b9d79be41 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/ClientCodeBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/ClientCodeBlock.tsx @@ -1,11 +1,14 @@ 'use client'; 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 { useInViewportListener } from '@/components/hooks/useInViewportListener'; 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 type { BlockProps } from '../Block'; import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression'; @@ -16,14 +19,17 @@ import { plainHighlight } from './plain-highlight'; type ClientBlockProps = Pick, 'block' | 'style'> & { inlines: RenderedInline[]; inlineExprVariables: InlineExpressionVariables; + mode: BlockProps['context']['mode']; }; +export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10; + /** * 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. */ export function ClientCodeBlock(props: ClientBlockProps) { - const { block, style, inlines, inlineExprVariables } = props; + const { block, mode, style, inlines, inlineExprVariables } = props; const blockRef = useRef(null); const isInViewportRef = useRef(false); const [isInViewport, setIsInViewport] = useState(false); @@ -113,13 +119,84 @@ export function ClientCodeBlock(props: ClientBlockProps) { setLines(null); }, [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 && mode !== 'print' && numberOfLinesOfCode > collapsedLineCount + ); + + const codeBlockBodyId = useId(); + + const renderer = ( ); + + return isExpandable ? ( + + {renderer} + + ) : ( + 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 ( +
+
+ {children} +
+
+ +
+
+ ); } diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlock.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlock.tsx index 206cb82ad..6ef4ee8cf 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlock.tsx @@ -50,7 +50,7 @@ export async function CodeBlock(props: BlockProps) { return { inline, body }; }); - if (!isEstimatedOffscreen && !hasInlineExpression) { + if (!isEstimatedOffscreen && !hasInlineExpression && !block.data.expandable) { // In v2, we render the code block server-side const lines = await highlight(block, richInlines); return ; @@ -73,6 +73,7 @@ export async function CodeBlock(props: BlockProps) { style={style} inlines={richInlines} inlineExprVariables={variables} + mode={context.mode} /> ); diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx index a3935ce9c..e50e874eb 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx @@ -12,6 +12,7 @@ import type { HighlightLine, HighlightToken } from './highlight'; type CodeBlockRendererProps = Pick, 'block' | 'style'> & { lines: HighlightLine[]; 'aria-busy'?: boolean; + id?: string; }; /** @@ -23,11 +24,12 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer( ) { const { block, style, lines, 'aria-busy': ariaBusy } = props; - const id = useId(); const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1; const withWrap = block.data.overflow === 'wrap'; const title = block.data.title; + const id = useId(); + const codeId = props.id || id; return (
                 ) {
                 alt="Drawing"
                 sizes={imageBlockSizes}
                 zoom
+                loading="lazy"
             />
         
     );
diff --git a/packages/gitbook/src/components/DocumentView/Embed.tsx b/packages/gitbook/src/components/DocumentView/Embed.tsx
index 0b433b689..3977d30af 100644
--- a/packages/gitbook/src/components/DocumentView/Embed.tsx
+++ b/packages/gitbook/src/components/DocumentView/Embed.tsx
@@ -60,6 +60,7 @@ export async function Embed(props: BlockProps) {
                                 sources={{ light: { src: embed.icon } }}
                                 sizes={[{ width: 20 }]}
                                 resize={context.contentContext.imageResizer}
+                                loading="lazy"
                             />
                         ) : null
                     }
diff --git a/packages/gitbook/src/components/DocumentView/Expandable/Details.tsx b/packages/gitbook/src/components/DocumentView/Expandable/Details.tsx
index f997225a2..8fd4d3570 100644
--- a/packages/gitbook/src/components/DocumentView/Expandable/Details.tsx
+++ b/packages/gitbook/src/components/DocumentView/Expandable/Details.tsx
@@ -17,29 +17,34 @@ export function Details(props: {
 }) {
     const { children, id, className } = props;
 
-    const detailsRef = React.useRef(null);
+    const ref = React.useRef(null);
 
     const [openFromHash, setOpenFromHash] = React.useState(false);
 
     const hash = useHash();
+
     /**
      * 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.
      */
     React.useEffect(() => {
-        if (!hash || !detailsRef.current) {
+        if (!hash || !ref.current) {
             return;
         }
+
         if (hash === id) {
             setOpenFromHash(true);
+            return;
         }
+
         const activeElement = document.getElementById(hash);
-        setOpenFromHash(Boolean(activeElement && detailsRef.current?.contains(activeElement)));
+        const isOpen = Boolean(activeElement && ref.current.contains(activeElement));
+        setOpenFromHash(isOpen);
     }, [hash, id]);
 
     return (
         
) { className={tcls( style, 'flex', - 'flex-row', - 'gap-3', + 'w-full', align === 'center' && 'justify-center', align === 'right' && 'justify-end', - 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', - ] + align === 'left' && 'justify-start' )} > - {block.nodes.map((node: any, _i: number) => ( - - ))} +
+ {block.nodes.map((node: any, _i: number) => ( + + ))} +
); } @@ -135,8 +143,7 @@ async function ImageBlock(props: { } : null, }} - priority={isEstimatedOffscreen ? 'lazy' : 'high'} - preload + loading={isEstimatedOffscreen ? 'lazy' : 'eager'} zoom inlineStyle={{ maxWidth: '100%', diff --git a/packages/gitbook/src/components/DocumentView/InlineIcon.tsx b/packages/gitbook/src/components/DocumentView/InlineIcon.tsx index d9c50605a..18ff08dff 100644 --- a/packages/gitbook/src/components/DocumentView/InlineIcon.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineIcon.tsx @@ -1,4 +1,4 @@ -import type { DocumentInlineIcon, DocumentMarkColor } from '@gitbook/api'; +import type { DocumentInlineIcon } from '@gitbook/api'; import { tcls } from '@/lib/tailwind'; import { Icon, type IconName } from '@gitbook/icons'; @@ -7,16 +7,11 @@ import { textColorToStyle } from './utils/colors'; export async function InlineIcon(props: InlineProps) { const { inline } = props; - const icon = inline.data.icon as IconName; - // @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; + const { color, icon } = inline.data; return ( ); diff --git a/packages/gitbook/src/components/DocumentView/InlineImage.tsx b/packages/gitbook/src/components/DocumentView/InlineImage.tsx index 894c56b57..2fe6d8111 100644 --- a/packages/gitbook/src/components/DocumentView/InlineImage.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineImage.tsx @@ -49,8 +49,7 @@ export async function InlineImage(props: InlineProps) { } : null, }} - priority="lazy" - preload + loading="lazy" style={[size === 'line' ? ['max-h-lh', 'h-lh', 'w-auto'] : null]} inline zoom={!isInLink} diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx index 90b3be5e2..e1b6b9a63 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx @@ -41,6 +41,7 @@ export function getOpenAPIContext(args: { plus: , copy: , check: , + lock: , }, renderCodeBlock: (codeProps) => , renderDocument: (documentProps) => ( diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css index a4b9f28f3..53602c41d 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css @@ -34,7 +34,7 @@ .openapi-deprecated, .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 { @@ -72,7 +72,7 @@ } .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 { @@ -95,7 +95,7 @@ /* Method Tags */ .openapi-method, .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, @@ -270,11 +270,11 @@ } .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 { - @apply rounded-r; + @apply rounded-r straight-corners:rounded-none circular-corners:rounded-r-md; } /* Schema Description */ @@ -308,7 +308,7 @@ .openapi-schema-pattern code, .openapi-schema-enum-value 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 */ @@ -325,16 +325,20 @@ @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 { @apply text-xs; } .openapi-securities-scopes ul { - @apply !my-0; + @apply !my-0 ml-4 pl-0; } .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 { @@ -394,15 +398,13 @@ @apply text-left prose-sm text-sm leading-tight text-tint select-text prose-strong:font-semibold prose-strong:text-inherit; } -.openapi-disclosure-group-trigger[aria-expanded="false"] { - .openapi-response-description.openapi-markdown { - @apply truncate; - @apply [&>*:not(:first-child)]:hidden *:truncate *:!p-0 *:!m-0; - } +.openapi-disclosure-group-trigger[aria-expanded="false"] .openapi-response-description.openapi-markdown { + @apply truncate; + @apply [&>*:not(:first-child)]:hidden *:truncate *:!p-0 *:!m-0; +} - .openapi-response-tab-content { - @apply basis-[60%] - } +.openapi-disclosure-group-trigger[aria-expanded="false"] .openapi-response-tab-content { + @apply basis-[60%]; } .openapi-response-body { @@ -454,6 +456,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; } +.openapi-codesample-header-content .openapi-path-title { + @apply block; +} + .openapi-codesample-header-content .openapi-path .openapi-path-variable { @apply text-[0.813rem]; } @@ -474,7 +480,7 @@ } .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 { @@ -486,7 +492,7 @@ } .openapi-path-title { - @apply flex-1 relative font-normal text-left overflow-x-auto 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 whitespace-nowrap md:whitespace-normal; scrollbar-width: none; -ms-overflow-style: none; @@ -520,7 +526,7 @@ .openapi-panel, .openapi-codesample, .openapi-response-examples { - @apply border shrink min-h-40 overflow-hidden rounded-md straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm; + @apply border shrink min-h-40 overflow-hidden rounded-lg straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm; } .openapi-response-examples-panel { @@ -597,8 +603,8 @@ body:has(.openapi-select-popover) { } .openapi-select > button { - @apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded leading-none; - @apply hover:bg-tint-hover transition-all; + @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 dark:hover:bg-tint-hover transition-all; } .openapi-select:not(.openapi-select-unstyled) > button { @@ -606,7 +612,7 @@ body:has(.openapi-select-popover) { } .openapi-select-unstyled > button { - @apply p-1; + @apply p-1 *:truncate max-w-full; } .openapi-select > button[data-focused="true"] { @@ -630,7 +636,7 @@ body:has(.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; } @@ -643,7 +649,7 @@ body:has(.openapi-select-popover) { } .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; } @@ -739,7 +745,7 @@ body:has(.openapi-select-popover) { } .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"] { @@ -810,12 +816,12 @@ body:has(.openapi-select-popover) { } .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-panel { - @apply straight-corners:!rounded-none; + @apply straight-corners:!rounded-none circular-corners:!rounded-md; } .openapi-disclosure-panel { @@ -843,7 +849,7 @@ body:has(.openapi-select-popover) { .openapi-schema-alternatives .openapi-disclosure, .openapi-schemas-disclosure .openapi-schema.openapi-disclosure ) { - @apply rounded-xl; + @apply rounded-md circular-corners:rounded-xl straight-corners:rounded-none; } .openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure { @@ -858,10 +864,10 @@ body:has(.openapi-select-popover) { @apply ring-1 shadow-sm; } -.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure):not(:first-child) { +.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure,.openapi-required-scopes):not(:first-child) { @apply mt-2; } -.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure):not(:last-child) { +.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure,.openapi-required-scopes):not(:last-child) { @apply mb-2; } @@ -993,12 +999,12 @@ body:has(.openapi-select-popover) { } .openapi-path-copy-button { - @apply p-1 flex rounded-md; - @apply hover:bg-tint; + @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 flex opacity-0 transition-all; + @apply size-6 opacity-0 transition-all hidden sm:flex; } .openapi-path:hover .openapi-path-copy-button-icon { @@ -1007,4 +1013,27 @@ body:has(.openapi-select-popover) { .openapi-path-copy-button-icon svg { @apply text-tint size-4; +} + +.openapi-required-scopes { + @apply border text-base rounded-md straight-corners:rounded-none circular-corners:rounded-md font-medium mx-0; +} + +.openapi-required-scopes .openapi-required-scopes-header { + @apply flex items-center gap-3; +} + +.openapi-required-scopes .openapi-required-scopes-header svg { + @apply size-3.5 text-tint-subtle rotate-none; +} + +.openapi-required-scopes .openapi-disclosure-group-panel { + @apply px-3 pb-3; +} +.openapi-required-scopes .openapi-securities-scopes { + @apply ml-6 font-normal *:!text-[0.8125rem]; +} + +.openapi-required-scopes .openapi-required-scopes-description { + @apply text-xs !text-tint font-normal mb-2; } \ No newline at end of file diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx index 57d814d93..93bff135b 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx @@ -60,7 +60,7 @@ export async function RecordCard( 'overflow-hidden', '[&_.heading>div:first-child]:hidden', '[&_.heading>div]:text-[.8em]', - 'md:[&_.heading>div]:text-[1em]', + '@xl:[&_.heading>div]:text-[1em]', '[&_.blocks:first-child_.heading]:pt-0', // Remove padding-top on first heading in card // On mobile, check if we can display the cover responsively or not: @@ -69,10 +69,10 @@ export async function RecordCard( lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait ? [ lightCoverIsSquareOrPortrait - ? 'grid-cols-[40%__1fr] min-[432px]:grid-cols-none min-[432px]:grid-rows-[auto_1fr]' + ? '@sm:grid-cols-none grid-cols-[40%__1fr] @sm:grid-rows-[auto_1fr]' : '', darkCoverIsSquareOrPortrait - ? 'dark:grid-cols-[40%__1fr] dark:min-[432px]:grid-cols-none dark:min-[432px]:grid-rows-[auto_1fr]' + ? 'dark:@sm:grid-cols-none dark:grid-cols-[40%__1fr] dark:@sm:grid-rows-[auto_1fr]' : '', ].filter(Boolean) : 'grid-rows-[auto_1fr]' @@ -85,11 +85,13 @@ export async function RecordCard( light: { src: lightCover.href, size: lightCover.file?.dimensions, + alt: light.alt, }, dark: darkCover ? { src: darkCover.href, size: darkCover.file?.dimensions, + alt: dark.alt, } : null, }} @@ -110,18 +112,15 @@ export async function RecordCard( 'bg-tint-subtle', lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait ? [ - lightCoverIsSquareOrPortrait - ? 'min-[432px]:aspect-video min-[432px]:h-auto' - : '', + lightCoverIsSquareOrPortrait ? '@sm:aspect-video @sm:h-auto' : '', darkCoverIsSquareOrPortrait - ? 'dark:min-[432px]:aspect-video dark:min-[432px]:h-auto' + ? 'dark:@sm:aspect-video dark:@sm:h-auto' : '', ].filter(Boolean) : ['h-auto', 'aspect-video'], objectFits )} - priority={isOffscreen ? 'lazy' : 'high'} - preload + loading={isOffscreen ? 'lazy' : 'eager'} /> ) : null}
( }, }, }} - priority="lazy" + loading="lazy" /> ) : ( ( size: image.file?.dimensions, }, }} - priority="lazy" + loading="lazy" /> {image.text} diff --git a/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx b/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx index b24ed146b..761078f98 100644 --- a/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx @@ -15,8 +15,8 @@ export function ViewCards(props: TableViewProps) { 'inline-grid', 'gap-4', 'grid-cols-1', - 'min-[432px]:grid-cols-2', - view.cardSize === 'large' ? 'md:grid-cols-2' : 'md:grid-cols-3', + '@sm:grid-cols-2', + view.cardSize === 'large' ? '@xl:grid-cols-2' : '@xl:grid-cols-3', block.data.fullWidth ? 'large:flex-column' : null )} > diff --git a/packages/gitbook/src/components/DocumentView/Table/utils.ts b/packages/gitbook/src/components/DocumentView/Table/utils.ts index 540df2dc4..8ada675ec 100644 --- a/packages/gitbook/src/components/DocumentView/Table/utils.ts +++ b/packages/gitbook/src/components/DocumentView/Table/utils.ts @@ -21,6 +21,12 @@ export function getRecordValue { const input = { id, tabs }; @@ -85,29 +87,33 @@ export function DynamicTabs(props: { ); }, [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 - // 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 // and get stuck into an inconsistent state. - const mounted = useIsMounted(); - const active = mounted ? activeState : tabs[0]; + const active = initialized ? activeState : tabs[0]; // When clicking to select a tab, we: // - update the URL hash // - mark this specific ID as selected // - store the ID to auto-select other tabs with the same title const selectTab = useCallback( - (tabId: string) => { + (tabId: string, manual = true) => { const tab = tabs.find((tab) => tab.id === tabId); if (!tab) { return; } - const href = `#${tab.id}`; - if (window.location.hash !== href) { - router.replace(href); - onNavigationClick(href); + if (manual) { + touchedRef.current = true; + const href = `#${tab.id}`; + if (window.location.hash !== href) { + router.replace(href, { scroll: false }); + } } setTabsState((prev) => { @@ -128,16 +134,18 @@ export function DynamicTabs(props: { }; }); }, - [onNavigationClick, router, setTabsState, tabs, id] + [router, setTabsState, tabs, id] ); // When the hash changes, we try to select the tab containing the targetted element. - React.useEffect(() => { + React.useLayoutEffect(() => { + setInitialized(true); + if (hash) { // First check if the hash matches a tab ID. const hashIsTab = tabs.some((tab) => tab.id === hash); if (hashIsTab) { - selectTab(hash); + selectTab(hash, false); return; } @@ -152,10 +160,39 @@ export function DynamicTabs(props: { return; } - selectTab(tabPanel.id); + 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; + } + + const activeElement = document.getElementById(hash); + if (!activeElement) { + return; + } + + activeElement.scrollIntoView({ + block: 'start', + behavior: 'instant', + }); + }, [active, tabs, hash]); + return (
- {tab.body} +
); }); diff --git a/packages/gitbook/src/components/DocumentView/spacing.ts b/packages/gitbook/src/components/DocumentView/spacing.ts index b77d18cb9..097a30e64 100644 --- a/packages/gitbook/src/components/DocumentView/spacing.ts +++ b/packages/gitbook/src/components/DocumentView/spacing.ts @@ -19,19 +19,19 @@ export function getBlockTextStyle(block: DocumentBlock): { }; case 'heading-1': return { - textSize: 'text-3xl font-semibold', + textSize: 'text-xl @xs:text-2xl @lg:text-3xl font-semibold', lineHeight: 'leading-tight', marginTop: 'column-first-of-type:pt-0 pt-[1em]', }; case 'heading-2': return { - textSize: 'text-2xl font-semibold', + textSize: 'text-lg @xs:text-xl @lg:text-2xl font-semibold', lineHeight: 'leading-snug', marginTop: 'column-first-of-type:pt-0 pt-[0.75em]', }; case 'heading-3': return { - textSize: 'text-xl font-semibold', + textSize: 'text-base @xs:text-lg @lg:text-xl font-semibold', lineHeight: 'leading-snug', marginTop: 'column-first-of-type:pt-0 pt-[0.5em]', }; diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableFrame.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableFrame.tsx index 9b233da75..438470fb3 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableFrame.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableFrame.tsx @@ -17,7 +17,7 @@ export const EmbeddableFrame = React.forwardRef -
+
- { - // Footer Logo - customization.footer.logo ? ( -
- Logo -
- ) : null - } - - { - // Theme Toggle - customization.themes.toggeable ? ( -
- - - -
- ) : null - } - - { - // Navigation groups (split into equal columns) - customization.footer.groups?.length > 0 ? ( -
-
- {partition(customization.footer.groups, FOOTER_COLUMNS).map( - (column, columnIndex) => ( -
- {column.map((group, groupIndex) => ( - - ))} -
- ) - )} +
+ { + // Footer Logo + customization.footer.logo ? ( +
+ Logo
-
- ) : null - } + ) : null + } - { - // Legal - customization.footer.copyright ? ( -
-

{customization.footer.copyright}

-
- ) : null - } + { + // Theme Toggle + customization.themes.toggeable ? ( +
+ + + +
+ ) : null + } + + { + // Navigation groups (split into equal columns) + customization.footer.groups?.length > 0 ? ( +
+
+ {partition(customization.footer.groups, FOOTER_COLUMNS).map( + (column, columnIndex) => ( +
+ {column.map((group, groupIndex) => ( + + ))} +
+ ) + )} +
+
+ ) : null + } + + { + // Legal + customization.footer.copyright ? ( +
+

{customization.footer.copyright}

+
+ ) : null + } +
diff --git a/packages/gitbook/src/components/Header/CurrentContentIcon.tsx b/packages/gitbook/src/components/Header/CurrentContentIcon.tsx index 71bcd375c..39e0f4664 100644 --- a/packages/gitbook/src/components/Header/CurrentContentIcon.tsx +++ b/packages/gitbook/src/components/Header/CurrentContentIcon.tsx @@ -51,6 +51,7 @@ export function CurrentContentIcon( }, } } + preload {...imageProps} /> ); diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index 38529ac07..fa0089bc2 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -3,6 +3,7 @@ import type { GitBookSiteContext } from '@/lib/context'; import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout'; import { getSpaceLanguage, t } from '@/intl/server'; import { tcls } from '@/lib/tailwind'; +import type { SiteSpace } from '@gitbook/api'; import { SearchContainer } from '../Search'; import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections'; import { HeaderLink } from './HeaderLink'; @@ -18,9 +19,12 @@ import { TranslationsDropdown } from './SpacesDropdown'; export function Header(props: { context: GitBookSiteContext; 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 withSections = Boolean( @@ -63,7 +67,7 @@ export function Header(props: { 'theme-bold:shadow-tint-12/2' )} > -
+
1 + ? 'lg:hidden' : 'page-no-toc:hidden lg:hidden' )} /> @@ -104,36 +114,37 @@ export function Header(props: { 'flex', 'grow-0', 'shrink-0', - 'md:basis-56', + '@2xl:basis-56', 'justify-self-end', 'items-center', 'gap-2', + 'transition-[margin] duration-300', 'search' in customization.styling && customization.styling.search === 'prominent' ? [ - 'md:grow-[0.8]', - 'lg:basis-40', - 'md:max-w-[40%]', - 'lg:max-w-lg', - 'lg:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - 'xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem) - 'md:mr-auto', + '@2xl:grow-[0.8]', + '@4xl:basis-40', + '@2xl:max-w-[40%]', + '@4xl:max-w-lg', + 'lg:@2xl:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) + 'not-chat-open:xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem) + '@2xl:mr-auto', 'order-last', - 'md:order-[unset]', + '@2xl:order-[unset]', ] : ['order-last'] )} > 1} withSiteVariants={ sections?.list.some( (s) => s.object === 'site-section' && s.siteSpaces.length > 1 ) ?? false } - withSections={!!sections} + withSections={sections ? sections.list.length > 1 : false} section={ sections ? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear @@ -150,7 +161,7 @@ export function Header(props: {
{customization.header.links.length > 0 || - (!withSections && withVariants === 'translations') ? ( + (!withSections && variants.translations.length > 1) ? ( {customization.header.links.length > 0 ? ( <> @@ -170,11 +181,15 @@ export function Header(props: { /> ) : null} - {!withSections && withVariants === 'translations' ? ( + {!withSections && variants.translations.length > 1 ? ( space.id === siteSpace.id + ) ?? siteSpace + } + siteSpaces={variants.translations} className="flex! theme-bold:text-header-link hover:theme-bold:bg-header-link/3" /> ) : null} @@ -187,14 +202,17 @@ export function Header(props: { {sections && withSections ? (
- {withVariants === 'translations' ? ( -
- -
+ {variants.translations.length > 1 ? ( + space.id === siteSpace.id + ) ?? siteSpace + } + siteSpaces={variants.translations} + className="my-2 ml-2 self-start" + /> ) : null}
diff --git a/packages/gitbook/src/components/Header/HeaderLinks.tsx b/packages/gitbook/src/components/Header/HeaderLinks.tsx index a96467c20..da2e01e83 100644 --- a/packages/gitbook/src/components/Header/HeaderLinks.tsx +++ b/packages/gitbook/src/components/Header/HeaderLinks.tsx @@ -14,7 +14,7 @@ export async function HeaderLinks({ children, style }: HeaderLinksProps) {
.button+.button]:-ml-2 z-20 ml-auto flex min-w-9 shrink grow items-center justify-end gap-x-4 lg:gap-x-6 xl:grow-0', + '@4xl:[&>.button+.button]:-ml-2 z-20 ml-auto flex min-w-9 shrink grow @7xl:grow-0 items-center justify-end @4xl:gap-x-6 gap-x-4', style )} > diff --git a/packages/gitbook/src/components/Header/HeaderLogo.tsx b/packages/gitbook/src/components/Header/HeaderLogo.tsx index ccea4b963..4239cb5f3 100644 --- a/packages/gitbook/src/components/Header/HeaderLogo.tsx +++ b/packages/gitbook/src/components/Header/HeaderLogo.tsx @@ -46,7 +46,7 @@ export async function HeaderLogo(props: HeaderLogoProps) { width: 260, }, ]} - priority="high" + preload style={tcls( 'overflow-hidden', 'shrink', diff --git a/packages/gitbook/src/components/Header/headerLinks.module.css b/packages/gitbook/src/components/Header/headerLinks.module.css index 2a5736b43..8384648e3 100644 --- a/packages/gitbook/src/components/Header/headerLinks.module.css +++ b/packages/gitbook/src/components/Header/headerLinks.module.css @@ -1,4 +1,4 @@ -@media (max-width: 1279px) { +@container header (width < 1280px) { .containerHeaderlinks { container-type: inline-size; container-name: headerlinks; diff --git a/packages/gitbook/src/components/PageActions/PageActions.tsx b/packages/gitbook/src/components/PageActions/PageActions.tsx index 09c7819df..e536c9604 100644 --- a/packages/gitbook/src/components/PageActions/PageActions.tsx +++ b/packages/gitbook/src/components/PageActions/PageActions.tsx @@ -11,7 +11,7 @@ import { Icon, type IconName, IconStyle } from '@gitbook/icons'; import assertNever from 'assert-never'; import QuickLRU from 'quick-lru'; import React from 'react'; -import { create } from 'zustand'; +import { createStore, useStore } from 'zustand'; type PageActionType = 'button' | 'dropdown-menu-item'; @@ -41,18 +41,13 @@ export function ActionOpenAssistant(props: { assistant: Assistant; type: PageAct type CopiedStore = { copied: 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 useCopiedStore = create< - CopiedStore & { - setLoading: (loading: boolean) => void; - copy: (data: string, opts?: { onSuccess?: () => void }) => void; - } ->((set) => { +const createCopiedStateStore = () => { let timeoutRef: ReturnType | null = null; - - return { + return createStore()((set) => ({ copied: false, loading: false, setLoading: (loading: boolean) => set({ loading }), @@ -70,13 +65,25 @@ const useCopiedStore = create< timeoutRef = setTimeout(() => { set({ copied: false }); onSuccess?.(); - - // Reset the timeout ref to avoid multiple timeouts timeoutRef = null; }, 1500); }, - }; -}); + })); +}; + +const copiedStores = new Map>(); + +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. @@ -96,7 +103,7 @@ export function ActionCopyMarkdown(props: { const closeDropdown = useDropdownMenuClose(); - const { copied, loading, setLoading, copy } = useCopiedStore(); + const { copied, loading, setLoading, copy } = useCopiedStore('markdown'); // Fetch the markdown from the page const fetchMarkdown = async () => { @@ -293,14 +300,29 @@ export function CopyToClipboard(props: { icon: IconName; }) { 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 ( { - navigator.clipboard.writeText(data); + onClick={(e) => { + e.preventDefault(); + + copy(data, { + onSuccess: () => { + if (type === 'dropdown-menu-item') { + closeDropdown(); + } + }, + }); }} /> ); diff --git a/packages/gitbook/src/components/PageAside/PageAside.tsx b/packages/gitbook/src/components/PageAside/PageAside.tsx index 6b14991f9..5bf96aeaf 100644 --- a/packages/gitbook/src/components/PageAside/PageAside.tsx +++ b/packages/gitbook/src/components/PageAside/PageAside.tsx @@ -37,24 +37,32 @@ export function PageAside(props: { 'group/aside', 'order-last', 'hidden', + 'max-w-0', 'pt-8', 'pb-4', + 'opacity-0', 'xl:flex', - 'xl:max-3xl:chat-open:hidden', - 'xl:max-3xl:chat-open:opacity-0', - 'max-w-56', - // Animate the width of the aside when the chat is open - 'xl:max-3xl:*:chat-open:w-56', + 'overflow-hidden', + + 'xl:max-w-56', + 'xl:opacity-11', + 'xl:ml-12', + + 'xl:max-3xl:chat-open:hidden', 'xl:max-3xl:chat-open:max-w-0', + 'xl:max-3xl:chat-open:opacity-0', 'xl:max-3xl:chat-open:ml-0', - 'motion-safe:xl:transition-[width,max-width,margin,opacity,display] motion-safe:xl:duration-300', - 'motion-safe:transition-discrete', + 'hydrated:starting:ml-0', + 'hydrated:starting:max-w-0', + 'hydrated:starting:opacity-0', + + 'transition-[margin,max-width,opacity,display] duration-300', + 'transition-discrete', 'basis-56', - 'xl:ml-12', 'grow-0', 'shrink-0', 'break-anywhere', // To prevent long words in headings from breaking the layout @@ -93,6 +101,7 @@ export function PageAside(props: {
page.type !== 'document' || (page.type === 'document' && !page.hidden) + ).length > 0; + return (
diff --git a/packages/gitbook/src/components/PageBody/PageCover.tsx b/packages/gitbook/src/components/PageBody/PageCover.tsx index 062331051..75871ac15 100644 --- a/packages/gitbook/src/components/PageBody/PageCover.tsx +++ b/packages/gitbook/src/components/PageBody/PageCover.tsx @@ -8,6 +8,7 @@ import { tcls } from '@/lib/tailwind'; import { assert } from 'ts-essentials'; import { PageCoverImage } from './PageCoverImage'; +import { getCoverHeight } from './coverHeight'; import defaultPageCoverSVG from './default-page-cover.svg'; const defaultPageCover = defaultPageCoverSVG as StaticImageData; @@ -22,6 +23,8 @@ export async function PageCover(props: { context: GitBookSiteContext; }) { const { as, page, cover, context } = props; + const height = getCoverHeight(cover); + const [resolved, resolvedDark] = await Promise.all([ cover.ref ? resolveContentRef(cover.ref, context) : null, cover.refDark ? resolveContentRef(cover.refDark, context) : null, @@ -108,6 +111,7 @@ export async function PageCover(props: { dark, }} y={cover.yPos} + height={height} />
); diff --git a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx index 22dc28f38..eb2190b74 100644 --- a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx +++ b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx @@ -1,8 +1,7 @@ 'use client'; import { tcls } from '@/lib/tailwind'; -import { useRef } from 'react'; -import { useResizeObserver } from 'usehooks-ts'; import type { ImageSize } from '../utils'; +import { useCoverPosition } from './useCoverPosition'; interface ImageAttributes { src: string; @@ -20,29 +19,27 @@ interface Images { const PAGE_COVER_SIZE: ImageSize = { width: 1990, height: 480 }; -function getTop(container: { height?: number; width?: number }, y: number, img: ImageAttributes) { - // When the size of the image hasn't been determined, we fallback to the center position - if (!img.size || y === 0) return '50%'; - const ratio = - container.height && container.width - ? Math.max(container.width / img.size.width, container.height / img.size.height) - : 1; - const scaledHeight = img.size ? img.size.height * ratio : PAGE_COVER_SIZE.height; - const top = - container.height && img.size ? (container.height - scaledHeight) / 2 + y * ratio : y; - return `${top}px`; +interface PageCoverImageProps { + imgs: Images; + y: number; + // Only if the `height` was customized by the user (and thus defined), we use it to set the cover's height and skip the default behaviour of fixed aspect-ratio. + height: number | undefined; } -export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) { - const containerRef = useRef(null); +export function PageCoverImage(props: PageCoverImageProps) { + const { imgs, y, height } = props; + const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y); - const container = useResizeObserver({ - // @ts-expect-error wrong types - ref: containerRef, - }); + if (isLoading) { + return ( +
+
+
+ ); + } return ( -
+
Page cover {imgs.dark && ( @@ -64,8 +64,11 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) { alt="Page cover" className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')} style={{ - aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`, - objectPosition: `50% ${getTop(container, y, imgs.dark)}`, + aspectRatio: height + ? undefined + : `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`, + objectPosition: `50% ${objectPositionY}%`, + height, // if no height is passed, no height will be set. }} /> )} diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index 66d483a9b..2c5c6aedc 100644 --- a/packages/gitbook/src/components/PageBody/PageHeader.tsx +++ b/packages/gitbook/src/components/PageBody/PageHeader.tsx @@ -100,11 +100,14 @@ export async function PageHeader(props: { {page.layout.title ? (

(null); + const [loadedDimensions, setLoadedDimensions] = useState(null); + const [isLoading, setIsLoading] = useState(!imgs.light.size && !imgs.dark?.size); + + const container = useResizeObserver({ + // @ts-expect-error wrong types + ref: containerRef, + }); + + // Load original image dimensions if not provided in `imgs` + useLayoutEffect(() => { + // Check if we have dimensions from dark (if provided) or else the default light. + const hasDimensions = imgs.dark?.size || imgs.light.size; + + if (hasDimensions) { + return; // Already have dimensions + } + + setIsLoading(true); + + // Load the original image (using src, not srcSet) to get true dimensions + // Use dark image if available, otherwise fall back to light + const imageToLoad = imgs.dark || imgs.light; + const img = new Image(); + img.onload = () => { + setLoadedDimensions({ + width: img.naturalWidth, + height: img.naturalHeight, + }); + setIsLoading(false); + }; + img.onerror = () => { + // If image fails to load, use a fallback + setIsLoading(false); + }; + img.src = imageToLoad.src; + }, [imgs.light, imgs.dark]); + + // Use provided dimensions or fall back to loaded dimensions + // Check dark first, then light, then loaded dimensions + const imageDimensions = imgs.dark?.size ?? imgs.light.size ?? loadedDimensions; + + // Calculate ratio and dimensions similar to useCoverPosition hook + const ratio = + imageDimensions && container.height && container.width + ? Math.max( + container.width / imageDimensions.width, + container.height / imageDimensions.height + ) + : 1; + const safeRatio = ratio || 1; + + const scaledHeight = + imageDimensions && container.height ? imageDimensions.height * safeRatio : null; + const maxOffset = + scaledHeight && container.height + ? Math.max(0, (scaledHeight - container.height) / 2 / safeRatio) + : 0; + + // Parse the position between the allowed min/max + const objectPositionY = useMemo(() => { + if (!container.height || !imageDimensions) { + return 50; + } + + const scaled = imageDimensions.height * safeRatio; + if (scaled <= container.height || maxOffset === 0) { + return 50; + } + + const clampedOffset = Math.max(-maxOffset, Math.min(maxOffset, y)); + const relative = (maxOffset - clampedOffset) / (2 * maxOffset); + return relative * 100; + }, [container.height, imageDimensions, maxOffset, safeRatio, y]); + + return { + containerRef, + objectPositionY, + isLoading: !imageDimensions || isLoading, + }; +} diff --git a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx index ad3e602b0..bf30a8000 100644 --- a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx +++ b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx @@ -1,21 +1,20 @@ '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 { readStreamableValue } from 'ai/rsc'; import React from 'react'; -import { Loading } from '@/components/primitives'; -import { useLanguage } from '@/intl/client'; -import { t } from '@/intl/translate'; -import type { TranslationLanguage } from '@/intl/translations'; -import { tcls } from '@/lib/tailwind'; - -import { AIResponseFeedback } from '../AIChat'; +import { AIResponseFeedback, AISearchIcon } from '../AIChat'; +import { HoldMessage } from '../AIChat/AIChatMessages'; import { useTrackEvent } from '../Insights'; -import { Link } from '../primitives'; +import { Button, Link } from '../primitives'; import { useSearchAskContext } from './SearchAskContext'; import { type AskAnswerResult, type AskAnswerSource, streamAskQuestion } from './server-actions'; -import { useSearchLink } from './useSearch'; +import { useSearch, useSearchLink } from './useSearch'; export type SearchAskState = | { @@ -84,8 +83,11 @@ export function SearchAskAnswer(props: { query: string }) { }, [setAskState]); const loading = ( -
- +
+
+ +
+
); @@ -129,8 +131,29 @@ function TransitionAnswerBody(props: { }); }, [answer]); + const language = useLanguage(); + const [, setSearchState] = useSearch(); + return display ? ( -
+
+
+
) : ( @@ -144,7 +167,7 @@ function AnswerBody(props: { query: string; answer: AskAnswerResult }) { return ( <> -
+
{answer.body ?? t(language, 'search_ask_no_answer')} {answer.sources.length > 0 ? ( // @TODO: Add responseId once search uses new AI endpoint diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index 3a9a40361..f2f29f7e6 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -15,7 +15,7 @@ import { useSearchAskState } from './SearchAskContext'; import { SearchAskProvider } from './SearchAskContext'; import { SearchInput } from './SearchInput'; import { SearchResults, type SearchResultsRef } from './SearchResults'; -import { SearchScopeToggle } from './SearchScopeToggle'; +import { SearchScopeControl } from './SearchScopeControl'; import { useSearch } from './useSearch'; import { useSearchResults } from './useSearchResults'; import { useSearchResultsCursor } from './useSearchResultsCursor'; @@ -215,27 +215,31 @@ export function SearchContainer(props: SearchContainerProps) { // Only show content if there's a query or Ask is enabled state?.query || withAI ? ( +
+ {state !== null && !showAsk ? ( + + ) : null} + {showAsk ? : null} +
{(withVariants || withSections) && !showAsk ? ( - +
+ +
) : null} - {state !== null && !showAsk ? ( - - ) : null} - {showAsk ? : null}
) : null } @@ -247,7 +251,7 @@ export function SearchContainer(props: SearchContainerProps) { onOpenAutoFocus: (event) => event.preventDefault(), align: 'start', 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) => { // Don't close if clicking on the search input itself if (searchInputRef.current?.contains(event.target as Node)) { diff --git a/packages/gitbook/src/components/Search/SearchInput.tsx b/packages/gitbook/src/components/Search/SearchInput.tsx index 88fa7311b..92e18a4e6 100644 --- a/packages/gitbook/src/components/Search/SearchInput.tsx +++ b/packages/gitbook/src/components/Search/SearchInput.tsx @@ -1,11 +1,12 @@ 'use client'; import React from 'react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; import { tString, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; import { Icon } from '@gitbook/icons'; import { Button, variantClasses } from '../primitives'; +import { KeyboardShortcut } from '../primitives/KeyboardShortcut'; import { useClassnames } from '../primitives/StyleProvider'; interface SearchInputProps { @@ -20,7 +21,7 @@ interface SearchInputProps { } // Size classes for medium size button -const sizeClasses = ['text-sm', 'px-3.5', 'py-1.5', 'md:circular-corners:px-4']; +const sizeClasses = ['text-sm', 'px-3.5', 'py-1.5', '@2xl:circular-corners:px-4']; /** * Input to trigger search. @@ -69,21 +70,21 @@ export const SearchInput = React.forwardRef( variantClasses.header, sizeClasses, // Additional custom styles - 'has-[input:focus]:-translate-y-px h-9 grow cursor-pointer px-2.5 has-[input:focus]:bg-tint-base has-[input:focus]:depth-subtle:shadow-lg has-[input:focus]:depth-subtle:shadow-primary-subtle has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-primary-hover md:cursor-text', + 'has-[input:focus]:-translate-y-px h-9 grow @2xl:cursor-text cursor-pointer px-2.5 has-[input:focus]:bg-tint-base has-[input:focus]:depth-subtle:shadow-lg has-[input:focus]:depth-subtle:shadow-primary-subtle has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-primary-hover', 'theme-bold:border-header-link/3 has-[input:focus-visible]:theme-bold:border-header-link/5 has-[input:focus-visible]:theme-bold:bg-header-link/3 has-[input:focus-visible]:theme-bold:ring-header-link/5', 'theme-bold:before:absolute theme-bold:before:inset-0 theme-bold:before:bg-header-background/7 theme-bold:before:backdrop-blur-xl ', // Special overlay to make the transparent colors of theme-bold visible. - 'relative z-30 max-w-none shrink grow justify-start max-md:absolute max-md:right-0', - isOpen ? 'max-md:w-56' : 'max-md:w-[38px]' + '@max-2xl:absolute relative @max-2xl:right-0 z-30 max-w-none shrink grow justify-start', + isOpen ? '@max-2xl:w-56' : '@max-2xl:w-[38px]' )} > {value && isOpen ? (
); } ); - -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(null); - - useEffect(() => { - setOperatingSystem(getOperatingSystem()); - }, []); - - return ( - - ); -} diff --git a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx index 208b016f1..751e23718 100644 --- a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx @@ -54,13 +54,13 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt 'transition-colors', 'font-normal', 'uppercase', - 'leading-none', + 'leading-snug', 'tracking-wider', 'mb-1', 'flex', 'flex-wrap', 'gap-x-2', - 'gap-y-1', + 'gap-y-0', 'items-center' )} > diff --git a/packages/gitbook/src/components/Search/SearchScopeControl.tsx b/packages/gitbook/src/components/Search/SearchScopeControl.tsx new file mode 100644 index 000000000..494770d90 --- /dev/null +++ b/packages/gitbook/src/components/Search/SearchScopeControl.tsx @@ -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; + 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 ( +
+ {t(language, 'search_scope_title')} + {withSections ? ( + + ) : null} + + {withVariants && (!withSections || !sectionScopeIsExtended) ? ( + + ) : null} +
+ ); +} + +function SearchScopeSectionControl(props: SearchScopeControlProps & { isExtended: boolean }) { + const { isExtended, section } = props; + + const language = useLanguage(); + const [, setSearchState] = useSearch(); + + return ( + +
+ {t( + language, + isExtended + ? 'search_scope_section_all' + : 'search_scope_section_current', + section?.title ?? '' + )} + +
+ + } + > + + setSearchState((prev) => (prev ? { ...prev, scope: 'default' } : null)) + } + > +
+ + {t(language, 'search_scope_section_all')} + + + {t(language, 'search_scope_section_all_description')} + +
+
+ + setSearchState((prev) => (prev ? { ...prev, scope: 'current' } : null)) + } + > +
+ + {t(language, 'search_scope_section_current', section?.title ?? '')} + + + {t(language, 'search_scope_section_current_description')} + +
+
+
+ ); +} + +function SearchScopeVariantControl(props: SearchScopeControlProps & { isExtended: boolean }) { + const { isExtended, spaceTitle, withSections } = props; + + const language = useLanguage(); + const [, setSearchState] = useSearch(); + + return ( + +
+ + {t( + language, + isExtended + ? 'search_scope_variant_all' + : 'search_scope_variant_current', + spaceTitle ?? '' + )} + + +
+ + } + > + + setSearchState((prev) => + prev ? { ...prev, scope: withSections ? 'current' : 'default' } : null + ) + } + > +
+ + {t(language, 'search_scope_variant_current', spaceTitle ?? '')} + + + {t(language, 'search_scope_variant_current_description')} + +
+
+ + setSearchState((prev) => (prev ? { ...prev, scope: 'extended' } : null)) + } + > +
+ + {t(language, 'search_scope_variant_all')} + + + {t(language, 'search_scope_variant_all_description')} + +
+
+
+ ); +} diff --git a/packages/gitbook/src/components/Search/SearchScopeToggle.tsx b/packages/gitbook/src/components/Search/SearchScopeToggle.tsx deleted file mode 100644 index 3c51174e3..000000000 --- a/packages/gitbook/src/components/Search/SearchScopeToggle.tsx +++ /dev/null @@ -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; - 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 ? ( -
- - - setSearchState({ - ...state, - scope: scopeIsExtended ? 'all' : 'default', - }) - } - /> - - setSearchState({ - ...state, - scope: scopeIsExtended ? 'extended' : 'current', - }) - } - /> - - {withSiteVariants ? ( - - } - > - - setSearchState({ - ...state, - scope: (() => { - switch (state.scope) { - case 'extended': - return 'current'; - case 'current': - return 'extended'; - case 'all': - return 'default'; - default: - return 'all'; - } - })(), - }) - } - active={scopeIsExtended} - > -
- -
- Include all variants -
- Search content in every language/version -
-
-
-
-
- ) : null} -
- ) : withVariants ? ( - - - setSearchState({ - ...state, - scope: 'default', - }) - } - /> - - setSearchState({ ...state, scope: 'all' })} - /> - - ) : null} - - ); -} diff --git a/packages/gitbook/src/components/Search/server-actions.tsx b/packages/gitbook/src/components/Search/server-actions.tsx index 307e4be6d..805dcd834 100644 --- a/packages/gitbook/src/components/Search/server-actions.tsx +++ b/packages/gitbook/src/components/Search/server-actions.tsx @@ -413,11 +413,21 @@ async function transformSitePageResult( icon: siteSection?.icon as IconName, label: siteSection.title, }, - (!siteSection || siteSection?.siteSpaces.length > 1) && siteSpace + (siteSection?.siteSpaces?.filter( + // If a space is the only one in its langauge, it's a translation variant and we don't want to show it. + (space) => + siteSection?.siteSpaces?.filter( + // Check if there are other spaces in the same language within the section. + (s) => s.space.language === space.space.language + ).length > 1 // We only want to show the space if there are other spaces in the same language within the section. + ).length ?? 0) > 1 && siteSpace ? { label: siteSpace.title, } : undefined, + ...pageItem.ancestors.map((ancestor) => ({ + label: ancestor.title, + })), ].filter((item) => item !== undefined), }; diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index 563339d27..25880f123 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -13,6 +13,7 @@ import { import { type Assistant, useAI } from '@/components/AI'; import { useTrackEvent } from '../Insights'; +import { isQuestion } from './isQuestion'; import type { SearchScope } from './useSearch'; export type ResultType = @@ -198,13 +199,16 @@ function withAskTriggers( return without; } + const queryIsQuestion = isQuestion(query); + return [ + ...(queryIsQuestion ? [] : (without ?? [])), ...assistants.map((assistant, index) => ({ type: 'question' as const, id: `question-${index}`, query, assistant, })), - ...(without ?? []), + ...(!queryIsQuestion ? [] : (without ?? [])), ]; } diff --git a/packages/gitbook/src/components/SitePage/SitePage.tsx b/packages/gitbook/src/components/SitePage/SitePage.tsx index 4f0643cee..d11b3c83a 100644 --- a/packages/gitbook/src/components/SitePage/SitePage.tsx +++ b/packages/gitbook/src/components/SitePage/SitePage.tsx @@ -7,7 +7,6 @@ import { } from '@gitbook/api'; import type { Metadata, Viewport } from 'next'; import { notFound, redirect } from 'next/navigation'; -import React from 'react'; import { PageAside } from '@/components/PageAside'; import { PageBody, PageCover } from '@/components/PageBody'; @@ -75,9 +74,7 @@ export async function SitePage(props: SitePageProps) { insightsDisplayContext={SiteInsightsDisplayContext.Site} />
- - - +
); @@ -95,6 +92,30 @@ export async function generateSitePageViewport(context: GitBookSiteContext): Pro }; } +/** + * A string concatenation of the site structure (sections and variants) titles. + */ +function getSiteStructureTitle(context: GitBookSiteContext): string | null { + const { sections, siteSpace, siteSpaces } = context; + + const title = []; + if ( + sections && + sections.current.default === false && // Only if the current section is not the default one + sections.list.filter((section) => section.object === 'site-section').length > 1 // Only if there are multiple sections + ) { + title.push(sections.current.title); + } + if ( + siteSpaces.length > 1 && // Only if there are multiple variants + siteSpace.default === false && // Only if the variant is not the default one + siteSpaces.filter((space) => space.space.language === siteSpace.space.language).length > 1 // Only if there are multiple variants *for the current language*. This filters out spaces that are "just" translations of each other, not versions. + ) { + title.push(siteSpace.title); + } + return title.join(' '); +} + export async function generateSitePageMetadata(props: SitePageProps): Promise { const { context, pageTarget } = await getPageDataWithFallback({ context: props.context, @@ -102,14 +123,25 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise ; +}; + +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[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' }, + ]); + }); +}); diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index 0a555f51c..aa963284d 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -10,11 +10,9 @@ import { Footer } from '@/components/Footer'; import { Header, HeaderLogo } from '@/components/Header'; import { TableOfContents } from '@/components/TableOfContents'; import { CONTAINER_STYLE } from '@/components/layout'; -import { tcls } from '@/lib/tailwind'; - -import { getSpaceLanguage } from '@/intl/server'; import type { VisitorAuthClaims } from '@/lib/adaptive'; import { GITBOOK_APP_URL } from '@/lib/env'; +import { tcls } from '@/lib/tailwind'; import { AIChatProvider } from '../AI'; import type { RenderAIMessageOptions } from '../AI'; import { AIChat } from '../AIChat'; @@ -27,6 +25,7 @@ import { SiteSectionList, encodeClientSiteSections } from '../SiteSections'; import { CurrentContentProvider } from '../hooks'; import { NavigationLoader } from '../primitives/NavigationLoader'; import { SpaceLayoutContextProvider } from './SpaceLayoutContext'; +import { categorizeVariants } from './categorizeVariants'; type SpaceLayoutProps = { context: GitBookSiteContext; @@ -105,16 +104,7 @@ export function SpaceLayout(props: SpaceLayoutProps) { const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None; const withSections = Boolean(sections && sections.list.length > 1); - - const currentLanguage = getSpaceLanguage(context); - const withVariants: 'generic' | 'translations' | undefined = - siteSpaces.length > 1 - ? siteSpaces.some( - (space) => space.space.language && space.space.language !== currentLanguage.locale - ) - ? 'translations' - : 'generic' - : undefined; + const variants = categorizeVariants(context); const withFooter = customization.themes.toggeable || @@ -125,7 +115,7 @@ export function SpaceLayout(props: SpaceLayoutProps) { return ( -
+
{customization.ai?.mode === CustomizationAIMode.Assistant ? ( @@ -139,7 +129,8 @@ export function SpaceLayout(props: SpaceLayoutProps) { 'lg:flex-row', 'lg:justify-center', CONTAINER_STYLE, - 'site-width-wide:max-w-full', + 'site-width-wide:max-w-screen-4xl', + 'hydrated:transition-[max-width] duration-300', // Ensure the footer is display below the viewport even if the content is not enough withFooter && [ @@ -157,6 +148,7 @@ export function SpaceLayout(props: SpaceLayoutProps) { className={tcls( 'hidden', 'pr-4', + 'mt-2', 'lg:flex', 'grow-0', 'dark:shadow-light/1', @@ -165,11 +157,15 @@ export function SpaceLayout(props: SpaceLayoutProps) { )} > - {withVariants === 'translations' ? ( + {variants.translations.length > 1 ? ( space.id === siteSpace.id + ) ?? siteSpace + } + siteSpaces={variants.translations} className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden" /> ) : null} @@ -183,7 +179,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
1} withSiteVariants={ sections?.list.some( (s) => @@ -213,14 +209,14 @@ export function SpaceLayout(props: SpaceLayoutProps) { sections={encodeClientSiteSections(context, sections)} /> )} - {withVariants === 'generic' && ( + {variants.generic.length > 1 ? ( - )} + ) : null} } /> diff --git a/packages/gitbook/src/components/SpaceLayout/categorizeVariants.ts b/packages/gitbook/src/components/SpaceLayout/categorizeVariants.ts new file mode 100644 index 000000000..94df27104 --- /dev/null +++ b/packages/gitbook/src/components/SpaceLayout/categorizeVariants.ts @@ -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, + }; +} diff --git a/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx b/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx index faa18f493..5f07a2dec 100644 --- a/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx @@ -7,14 +7,14 @@ import { tcls } from '@/lib/tailwind'; import { PagesList } from './PagesList'; import { TOCPageIcon } from './TOCPageIcon'; -export function PageGroupItem(props: { page: ClientTOCPageGroup }) { - const { page } = props; +export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boolean }) { + const { page, isFirst } = props; return ( -
  • +
  • diff --git a/packages/gitbook/src/components/TableOfContents/PagesList.tsx b/packages/gitbook/src/components/TableOfContents/PagesList.tsx index dfdff1c9f..e00ee94b4 100644 --- a/packages/gitbook/src/components/TableOfContents/PagesList.tsx +++ b/packages/gitbook/src/components/TableOfContents/PagesList.tsx @@ -9,12 +9,16 @@ import { PageDocumentItem } from './PageDocumentItem'; import { PageGroupItem } from './PageGroupItem'; import { PageLinkItem } from './PageLinkItem'; -export function PagesList(props: { pages: ClientTOCPage[]; style?: ClassValue }) { - const { pages, style } = props; +export function PagesList(props: { + pages: ClientTOCPage[]; + style?: ClassValue; + isRoot?: boolean; +}) { + const { pages, style, isRoot = false } = props; return (
      - {pages.map((page) => { + {pages.map((page, idx) => { switch (page.type) { case 'document': return ; @@ -23,7 +27,13 @@ export function PagesList(props: { pages: ClientTOCPage[]; style?: ClassValue }) return ; case 'group': - return ; + return ( + + ); default: assertNever(page); diff --git a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx index ed8c51466..632135a28 100644 --- a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx +++ b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx @@ -59,7 +59,7 @@ export async function TableOfContents(props: { 'lg:page-no-toc:[html[style*="--outline-top-offset"]_&]:top-(--outline-top-offset)!', 'lg:page-no-toc:[html[style*="--outline-height"]_&]:top-(--outline-height)!', - 'pt-6', + 'pt-4', 'pb-4', 'lg:sidebar-filled:pr-6', 'lg:page-no-toc:pr-0', @@ -104,13 +104,14 @@ export async function TableOfContents(props: { ) : null} {customization.trademark.enabled ? ( diff --git a/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx b/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx index 9ab582ea0..4d222da04 100644 --- a/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx @@ -1,7 +1,7 @@ 'use client'; import { Icon } from '@gitbook/icons'; -import { motion } from 'motion/react'; +import { AnimatePresence, motion } from 'motion/react'; import React, { useRef } from 'react'; import { tcls } from '@/lib/tailwind'; @@ -184,9 +184,6 @@ const show = { const hide = { opacity: 0, height: 0, - transitionEnd: { - display: 'none', - }, }; function Descendants(props: { @@ -195,12 +192,17 @@ function Descendants(props: { }) { const { isVisible, children } = props; return ( - - {children} - + + {isVisible ? ( + + {children} + + ) : null} + ); } diff --git a/packages/gitbook/src/components/hooks/useHash.tsx b/packages/gitbook/src/components/hooks/useHash.tsx index aff0358e6..fecbb9d7e 100644 --- a/packages/gitbook/src/components/hooks/useHash.tsx +++ b/packages/gitbook/src/components/hooks/useHash.tsx @@ -51,6 +51,8 @@ export const NavigationStatusProvider: React.FC = ({ ch // Cleanup timeout on unmount React.useEffect(() => { + // Initialize hash on mount - It could be null on SSR rehydration + setHash(getHash()); return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); @@ -73,7 +75,8 @@ export const NavigationStatusProvider: React.FC = ({ ch if (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(() => { setIsNavigating(true); timeoutRef.current = null; @@ -94,7 +97,7 @@ export const NavigationStatusProvider: React.FC = ({ 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 * 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. diff --git a/packages/gitbook/src/components/hooks/usePrevious.ts b/packages/gitbook/src/components/hooks/usePrevious.ts new file mode 100644 index 000000000..5a3adfa6f --- /dev/null +++ b/packages/gitbook/src/components/hooks/usePrevious.ts @@ -0,0 +1,12 @@ +import * as React from 'react'; + +/** + * Returns the value of the previous render. + */ +export function usePrevious(value: T): T | undefined { + const ref = React.useRef(undefined); + React.useLayoutEffect(() => { + ref.current = value; + }); + return ref.current; +} diff --git a/packages/gitbook/src/components/hooks/useScrollPage.ts b/packages/gitbook/src/components/hooks/useScrollPage.ts index 1b1219a29..a63159fb5 100644 --- a/packages/gitbook/src/components/hooks/useScrollPage.ts +++ b/packages/gitbook/src/components/hooks/useScrollPage.ts @@ -1,37 +1,32 @@ 'use client'; -import { usePathname } from 'next/navigation'; import React from 'react'; import { useHash } from './useHash'; +import { usePrevious } from './usePrevious'; /** - * Scroll the page to an anchor point or - * to the top of the page when navigating between pages (pathname) - * or sections of a page (hash). + * Scroll the page to the hash or reset scroll to the top. + * Only triggered while navigating in the app, not for initial load. */ export function useScrollPage() { const hash = useHash(); - const pathname = usePathname(); - React.useLayoutEffect(() => { + const previousHash = usePrevious(hash); + + React.useEffect(() => { if (hash) { - const element = document.getElementById(hash); - if (element) { - element.scrollIntoView({ - block: 'start', - behavior: 'smooth', - }); - } - } else { - window.scrollTo(0, 0); - } - return () => { - if (hash) { + if (previousHash !== undefined && previousHash !== hash) { const element = document.getElementById(hash); if (element) { - element.style.scrollMarginTop = ''; + element.scrollIntoView({ + block: 'start', + behavior: 'smooth', + }); } } - }; - }, [hash, pathname]); + return; + } + + window.scrollTo(0, 0); + }, [hash, previousHash]); } diff --git a/packages/gitbook/src/components/primitives/DropdownMenu.tsx b/packages/gitbook/src/components/primitives/DropdownMenu.tsx index 45a1c63d0..87160de36 100644 --- a/packages/gitbook/src/components/primitives/DropdownMenu.tsx +++ b/packages/gitbook/src/components/primitives/DropdownMenu.tsx @@ -24,6 +24,11 @@ const DropdownMenuContext = createContext<{ setOpen: () => {}, }); +const DROPDOWN_CONTENT_OUTER_CLASS = + 'z-50 flex max-h-(--radix-dropdown-menu-content-available-height) min-w-28 xs:min-w-40 max-w-(--radix-dropdown-menu-content-available-width) origin-top animate-scale-in flex-col pt-2 data-[state=closed]:animate-scale-out'; +const DROPDOWN_CONTENT_INNER_CLASS = + 'flex flex-col gap-1 overflow-auto circular-corners:rounded-xl rounded-md straight-corners:rounded-none border border-tint bg-tint-base p-2 shadow-lg'; + /** * Button with a dropdown. */ @@ -82,14 +87,9 @@ export function DropdownMenu(props: { onMouseLeave={() => setHovered(false)} align={align} side={side} - className="z-50 animate-scale-in border-tint pt-2" + className={DROPDOWN_CONTENT_OUTER_CLASS} > -
      +
      {children}
      @@ -162,7 +162,7 @@ export function DropdownMenuItem( } = props; const itemClassName = tcls( - 'rounded-xs straight-corners:rounded-xs circular-corners:rounded-lg px-3 py-1 text-sm flex gap-2 items-center', + 'rounded-sm straight-corners:rounded-none circular-corners:rounded-lg px-3 py-1 text-sm flex gap-2 items-center', active ? 'bg-primary text-primary-strong data-highlighted:bg-primary-hover' : 'data-highlighted:bg-tint-hover', @@ -214,11 +214,9 @@ export function DropdownSubMenu(props: { children: React.ReactNode; label: React -
      - {children} -
      +
      {children}
      diff --git a/packages/gitbook/src/components/primitives/KeyboardShortcut.tsx b/packages/gitbook/src/components/primitives/KeyboardShortcut.tsx index 891fc5efd..49705139a 100644 --- a/packages/gitbook/src/components/primitives/KeyboardShortcut.tsx +++ b/packages/gitbook/src/components/primitives/KeyboardShortcut.tsx @@ -28,7 +28,7 @@ export function KeyboardShortcut(props: { keys: string[]; className?: ClassValue className={tcls( 'shortcut hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt","case"] contrast-more:text-tint-strong md:flex', operatingSystem - ? 'motion-safe:animate-fade-in motion-reduce:opacity-100' + ? 'motion-safe:animate-fade-in motion-reduce:opacity-11' : 'opacity-0' )} > diff --git a/packages/gitbook/src/components/primitives/NavigationLoader.tsx b/packages/gitbook/src/components/primitives/NavigationLoader.tsx index 1ce47ad16..76c63f880 100644 --- a/packages/gitbook/src/components/primitives/NavigationLoader.tsx +++ b/packages/gitbook/src/components/primitives/NavigationLoader.tsx @@ -1,9 +1,31 @@ 'use client'; import { tcls } from '@/lib/tailwind'; +import { usePathname } from 'next/navigation'; +import { useEffect, useLayoutEffect } from 'react'; import { useIsNavigating } from '../hooks'; export const NavigationLoader = () => { const isNavigating = useIsNavigating(); + const pathname = usePathname(); + + // Mark client hydration so initial paint doesn't animate. + useEffect(() => { + document.documentElement.classList.add('hydrated'); + }, []); + + // On route changes, add a transient class for the first paint of the new page. + useLayoutEffect(() => { + void pathname; + const root = document.documentElement; + root.classList.add('route-change'); + const raf1 = requestAnimationFrame(() => { + const raf2 = requestAnimationFrame(() => { + root.classList.remove('route-change'); + }); + return () => cancelAnimationFrame(raf2); + }); + return () => cancelAnimationFrame(raf1); + }, [pathname]); return (
      ; export function ScrollContainer(props: ScrollContainerProps) { - const { children, className, orientation, activeId, ...rest } = props; + const { + children, + className, + orientation, + activeId, + leadingEdgeScrollClassName, + trailingEdgeScrollClassName, + ...rest + } = props; const containerRef = React.useRef(null); @@ -32,56 +47,53 @@ export function ScrollContainer(props: ScrollContainerProps) { const language = useLanguage(); + useScrollListener(() => { + const container = containerRef.current; + if (!container) { + return; + } + + setScrollPosition( + orientation === 'horizontal' ? container.scrollLeft : container.scrollTop + ); + }, containerRef); + React.useEffect(() => { const container = containerRef.current; if (!container) { return; } - // Update scroll position on scroll using requestAnimationFrame - const scrollListener: EventListener = () => { - requestAnimationFrame(() => { - setScrollPosition( - orientation === 'horizontal' ? container.scrollLeft : container.scrollTop - ); - }); - }; - container.addEventListener('scroll', scrollListener); - // Update max scroll position using resize observer - const resizeObserver = new ResizeObserver((entries) => { - const containerEntry = entries.find((i) => i.target === containerRef.current); - if (containerEntry) { + const ro = new ResizeObserver((entries) => { + const [entry] = entries; + if (entry) { setScrollSize( orientation === 'horizontal' - ? containerEntry.target.scrollWidth - containerEntry.target.clientWidth - 1 - : containerEntry.target.scrollHeight - - containerEntry.target.clientHeight - - 1 + ? entry.target.scrollWidth - entry.target.clientWidth - 1 + : entry.target.scrollHeight - entry.target.clientHeight - 1 ); } }); - resizeObserver.observe(container); - return () => { - container.removeEventListener('scroll', scrollListener); - resizeObserver.disconnect(); - }; + ro.observe(container); + + return () => ro.disconnect(); }, [orientation]); - // Scroll to the active item React.useEffect(() => { const container = containerRef.current; - if (!container || !activeId) { + if (!container) { return; } - const activeItem = container.querySelector(`#${CSS.escape(activeId)}`); - if (activeItem) { - activeItem.scrollIntoView({ - inline: 'center', - block: 'center', - }); + if (!activeId) { + return; } + const activeItem = document.getElementById(activeId); + if (!activeItem || !container.contains(activeItem)) { + return; + } + scrollToElementInContainer(activeItem, container); }, [activeId]); const scrollFurther = () => { @@ -89,6 +101,7 @@ export function ScrollContainer(props: ScrollContainerProps) { if (!container) { return; } + container.scrollTo({ top: orientation === 'vertical' ? scrollPosition + container.clientHeight : undefined, left: orientation === 'horizontal' ? scrollPosition + container.clientWidth : undefined, @@ -101,6 +114,7 @@ export function ScrollContainer(props: ScrollContainerProps) { if (!container) { return; } + container.scrollTo({ top: orientation === 'vertical' ? scrollPosition - container.clientHeight : undefined, left: orientation === 'horizontal' ? scrollPosition - container.clientWidth : undefined, @@ -110,7 +124,12 @@ export function ScrollContainer(props: ScrollContainerProps) { return (
      0 ? leadingEdgeScrollClassName : '', + scrollPosition < scrollSize ? trailingEdgeScrollClassName : '' + )} {...rest} > {/* Scrollable content */} @@ -140,13 +159,13 @@ export function ScrollContainer(props: ScrollContainerProps) { icon={orientation === 'horizontal' ? 'chevron-left' : 'chevron-up'} iconOnly size="xsmall" - variant="secondary" + variant="header" tabIndex={-1} className={tcls( orientation === 'horizontal' ? '-translate-y-1/2! top-1/2 left-0 ml-2' : '-translate-x-1/2! top-0 left-1/2 mt-2', - 'absolute not-pointer-none:block hidden scale-0 opacity-0 transition-[scale,opacity]', + 'absolute not-pointer-none:block hidden scale-0 opacity-0 backdrop-blur-xl transition-[scale,opacity]', scrollPosition > 0 ? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11' : 'pointer-events-none' @@ -158,13 +177,13 @@ export function ScrollContainer(props: ScrollContainerProps) { icon={orientation === 'horizontal' ? 'chevron-right' : 'chevron-down'} iconOnly size="xsmall" - variant="secondary" + variant="header" tabIndex={-1} className={tcls( orientation === 'horizontal' ? '-translate-y-1/2! top-1/2 right-0 mr-2' : '-translate-x-1/2! bottom-0 left-1/2 mb-2', - 'absolute not-pointer-none:block hidden scale-0 transition-[scale,opacity]', + 'absolute not-pointer-none:block hidden scale-0 backdrop-blur-xl transition-[scale,opacity]', scrollPosition < scrollSize ? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11' : 'pointer-events-none' @@ -175,3 +194,25 @@ export function ScrollContainer(props: ScrollContainerProps) {
      ); } + +/** + * Scroll to an element in a container. + */ +function scrollToElementInContainer(element: HTMLElement, container: HTMLElement) { + const containerRect = container.getBoundingClientRect(); + const rect = element.getBoundingClientRect(); + + return container.scrollTo({ + top: + container.scrollTop + + (rect.top - containerRect.top) - + container.clientHeight / 2 + + rect.height / 2, + left: + container.scrollLeft + + (rect.left - containerRect.left) - + container.clientWidth / 2 + + rect.width / 2, + behavior: 'smooth', + }); +} diff --git a/packages/gitbook/src/components/utils/Image.tsx b/packages/gitbook/src/components/utils/Image.tsx index 87debcaa7..2675336cf 100644 --- a/packages/gitbook/src/components/utils/Image.tsx +++ b/packages/gitbook/src/components/utils/Image.tsx @@ -3,23 +3,26 @@ import type { ImageResizer } from '@/lib/images'; import ReactDOM from 'react-dom'; import { type ClassValue, tcls } from '@/lib/tailwind'; - -import { checkIsHttpURL } from '@/lib/urls'; import { ZoomImage } from './ZoomImage'; import type { PolymorphicComponentProp } from './types'; export type ImageSize = { width: number; height: number }; -type ImageSource = { +type DefaultImageSource = { src: string; - size?: ImageSize; aspectRatio?: string; + /** + * Override the alt attribute. + */ + alt?: string; }; -export type ImageSourceSized = { - src: string; +type ImageSource = DefaultImageSource & { + size?: ImageSize; +}; + +export type ImageSourceSized = DefaultImageSource & { size: ImageSize | null; - aspectRatio?: string; }; export type ImageResponsiveSize = { @@ -60,16 +63,6 @@ interface ImageCommonProps { */ zoom?: boolean; - /** - * Priority of the image. - * - `lazy` will load the image only when it's visible in the viewport. - * - `eager` will load the image as soon as possible, even if it's not visible; but will not preload. - * - `high` will preload. - * - * @default normal - */ - priority?: 'lazy' | 'normal' | 'high'; - /** * Force preloading the image. * Even if the priority is set to `lazy`, the image will be preloaded. @@ -117,7 +110,7 @@ export function Image( } & ImageCommonProps > ) { - const { sources, style, inline = false, ...rest } = props; + const { sources, style, inline = false, alt, ...rest } = props; return ( <> @@ -131,6 +124,7 @@ export function Image( sources.dark ? 'dark:hidden' : null, style )} + alt={sources.light.alt || alt} /> {sources.dark ? ( ) : null} @@ -198,11 +193,12 @@ async function ImagePictureSized( style: _style, alt, quality = 100, - priority = 'normal', inline = false, zoom = false, resize = false, preload = false, + loading, + fetchPriority, inlineStyle, ...rest } = props; @@ -212,9 +208,6 @@ async function ImagePictureSized( } const attrs = await getImageAttributes({ sizes, source, quality, resize }); - const canBeFetched = checkIsHttpURL(attrs.src); - const fetchPriority = canBeFetched ? getFetchPriority(priority) : undefined; - const loading = priority === 'lazy' ? 'lazy' : undefined; const aspectRatioStyle = source.aspectRatio ? { aspectRatio: source.aspectRatio } : {}; const style = { ...aspectRatioStyle, ...inlineStyle }; @@ -310,14 +303,3 @@ export async function getImageAttributes(params: { ...source.size, }; } - -function getFetchPriority(priority: ImageCommonProps['priority']) { - switch (priority) { - case 'lazy': - return 'low'; - case 'high': - return 'high'; - default: - return undefined; - } -} diff --git a/packages/gitbook/src/intl/translations/de.ts b/packages/gitbook/src/intl/translations/de.ts index 7a10c8d91..6b90a9b1e 100644 --- a/packages/gitbook/src/intl/translations/de.ts +++ b/packages/gitbook/src/intl/translations/de.ts @@ -1,11 +1,15 @@ export const de = { locale: 'de', + language: 'Deutsch', + flag: '🇩🇪', powered_by_gitbook: 'Bereitgestellt von GitBook', sponsored_via_gitbook: 'Gesponsert von GitBook', switch_to_dark_theme: 'Zum dunklen Modus wechseln', switch_to_light_theme: 'Zum hellen Modus wechseln', switch_to_system_theme: 'Zum Systemmodus wechseln', search: 'Suche', + clear: 'Löschen', + search_back: 'Zurück zu den Suchergebnissen', search_or_ask: 'Fragen oder Suchen', search_input_placeholder: 'Inhalt durchsuchen', search_ask_input_placeholder: 'Inhalt durchsuchen oder eine Frage stellen', @@ -16,10 +20,15 @@ export const de = { search_section_result_title: 'Abschnitt mit Titel ${1}', search_section_result_content: 'Abschnitt mit Inhalt ${1}', search_section_result_default: 'Abschnitt', - search_scope_current: '${1}', - search_scope_all: 'Überall', - search_scope_include_all_title: 'Alle Varianten einschließen', - search_scope_include_all_description: 'Inhalt in jeder Sprache/Version durchsuchen', + search_scope_title: 'Filter', + search_scope_section_current: '${1}', + search_scope_section_current_description: 'Nur der aktuelle Abschnitt', + search_scope_section_all: 'Gesamte Dokumentation', + search_scope_section_all_description: 'Alle Abschnitte der Dokumentation', + search_scope_variant_current: '${1}', + search_scope_variant_current_description: 'Nur die aktuelle Variante', + search_scope_variant_all: 'Alle Inhalte', + search_scope_variant_all_description: 'Jede verfügbare Variante', ask: 'Fragen', search_ask: 'Fragen "${1}"', search_ask_description: 'Finden Sie die Antwort mit ${1}', @@ -44,6 +53,8 @@ export const de = { annotation_button_label: 'Kommentar öffnen', code_copied: 'Kopiert!', code_copy: 'Kopieren', + code_block_collapsed: 'Alle ${1} Zeilen anzeigen', + code_block_expanded: 'Weniger anzeigen', table_of_contents_button_label: 'Inhaltsverzeichnis öffnen', cookies_title: 'Cookies', cookies_prompt: @@ -77,6 +88,7 @@ export const de = { link_tooltip_email: 'E-Mail senden an', link_tooltip_page_anchor: 'Zum Abschnitt springen', open_in_new_tab: 'In neuem Tab öffnen', + ai_answer: 'KI-Antwort', ai_chat_assistant_name: 'GitBook-Assistent', ai_chat_assistant_name_unbranded: 'KI-Assistent', ai_chat_assistant_description: 'Ich helfe Ihnen bei der Dokumentation.', diff --git a/packages/gitbook/src/intl/translations/en.ts b/packages/gitbook/src/intl/translations/en.ts index 143d27a5b..d06e39a9c 100644 --- a/packages/gitbook/src/intl/translations/en.ts +++ b/packages/gitbook/src/intl/translations/en.ts @@ -1,11 +1,15 @@ export const en = { locale: 'en', + language: 'English', + flag: '🇺🇸', powered_by_gitbook: 'Powered by GitBook', sponsored_via_gitbook: 'Sponsored via GitBook', switch_to_dark_theme: 'Switch to dark theme', switch_to_light_theme: 'Switch to light theme', switch_to_system_theme: 'Switch to system theme', search: 'Search', + clear: 'Clear', + search_back: 'Back to search results', search_or_ask: 'Ask or search', search_input_placeholder: 'Search content', search_ask_input_placeholder: 'Search content or ask a question', @@ -16,10 +20,15 @@ export const en = { search_section_result_title: 'Section with title ${1}', search_section_result_content: 'Section with content ${1}', search_section_result_default: 'Section', - search_scope_current: '${1}', - search_scope_all: 'Everywhere', - search_scope_include_all_title: 'Include all variants', - search_scope_include_all_description: 'Search content in every language/version', + search_scope_title: 'Filter', + search_scope_section_current: '${1}', + search_scope_section_current_description: 'Only the current section', + search_scope_section_all: 'All docs', + search_scope_section_all_description: 'All sections of the docs', + search_scope_variant_current: '${1}', + search_scope_variant_current_description: 'Only the current variant', + search_scope_variant_all: 'All content', + search_scope_variant_all_description: 'Every available variant', ask: 'Ask', search_ask: 'Ask "${1}"', search_ask_description: 'Find the answer with ${1}', @@ -44,6 +53,8 @@ export const en = { annotation_button_label: 'Open annotation', code_copied: 'Copied!', code_copy: 'Copy', + code_block_collapsed: 'Show all ${1} lines', + code_block_expanded: 'Show less', table_of_contents_button_label: 'Open table of contents', cookies_title: 'Cookies', cookies_prompt: @@ -74,6 +85,7 @@ export const en = { link_tooltip_email: 'Send an email to', link_tooltip_page_anchor: 'Jump to section', open_in_new_tab: 'Open in new tab', + ai_answer: 'AI Answer', ai_chat_assistant_name: 'GitBook Assistant', ai_chat_assistant_name_unbranded: 'AI Assistant', ai_chat_assistant_description: "I'm here to help you with the docs.", diff --git a/packages/gitbook/src/intl/translations/es.ts b/packages/gitbook/src/intl/translations/es.ts index 406f209f1..8f95415e2 100644 --- a/packages/gitbook/src/intl/translations/es.ts +++ b/packages/gitbook/src/intl/translations/es.ts @@ -2,12 +2,16 @@ import type { TranslationLanguage } from './types'; export const es: TranslationLanguage = { locale: 'es', + language: 'Español', + flag: '🇪🇸', powered_by_gitbook: 'Con tecnología de GitBook', sponsored_via_gitbook: 'Patrocinado por GitBook', switch_to_dark_theme: 'Cambiar a tema oscuro', switch_to_light_theme: 'Cambiar a tema claro', switch_to_system_theme: 'Cambiar a tema del sistema', search: 'Buscar', + clear: 'Limpiar', + search_back: 'Volver a los resultados de búsqueda', search_or_ask: 'Preguntar o Buscar', search_input_placeholder: 'Buscar contenido', search_ask_input_placeholder: 'Buscar contenido o hacer una pregunta', @@ -18,10 +22,15 @@ export const es: TranslationLanguage = { search_section_result_title: 'Sección con título ${1}', search_section_result_content: 'Sección con contenido ${1}', search_section_result_default: 'Sección', - search_scope_current: '${1}', - search_scope_all: 'En todas partes', - search_scope_include_all_title: 'Incluir todas las variantes', - search_scope_include_all_description: 'Buscar contenido en todos los idiomas/versiones', + search_scope_title: 'Filtro', + search_scope_section_current: '${1}', + search_scope_section_current_description: 'Solo la sección actual', + search_scope_section_all: 'Toda la documentación', + search_scope_section_all_description: 'Todas las secciones de la documentación', + search_scope_variant_current: '${1}', + search_scope_variant_current_description: 'Solo la variante actual', + search_scope_variant_all: 'Todo el contenido', + search_scope_variant_all_description: 'Cada variante disponible', ask: 'Preguntar', search_ask: 'Preguntar "${1}"', search_ask_description: 'Encuentra la respuesta con ${1}', @@ -46,6 +55,8 @@ export const es: TranslationLanguage = { annotation_button_label: 'Abrir anotación', code_copied: '¡Copiado!', code_copy: 'Copiar', + code_block_collapsed: 'Mostrar las ${1} líneas', + code_block_expanded: 'Mostrar menos', table_of_contents_button_label: 'Abrir índice de contenidos', cookies_title: 'Cookies', cookies_prompt: @@ -78,6 +89,7 @@ export const es: TranslationLanguage = { link_tooltip_email: 'Enviar un correo electrónico a', link_tooltip_page_anchor: 'Saltar a la sección', open_in_new_tab: 'Abrir en una nueva pestaña', + ai_answer: 'Respuesta de IA', ai_chat_assistant_name: 'Asistente de GitBook', ai_chat_assistant_name_unbranded: 'Asistente de IA', ai_chat_assistant_description: 'Estoy aquí para ayudarte con la documentación.', diff --git a/packages/gitbook/src/intl/translations/fr.ts b/packages/gitbook/src/intl/translations/fr.ts index 7bc255960..9548630d9 100644 --- a/packages/gitbook/src/intl/translations/fr.ts +++ b/packages/gitbook/src/intl/translations/fr.ts @@ -1,11 +1,15 @@ export const fr = { locale: 'fr', + language: 'Français', + flag: '🇫🇷', powered_by_gitbook: 'Propulsé par GitBook', sponsored_via_gitbook: 'Sponsorisé via GitBook', switch_to_dark_theme: 'Activer le thème sombre', switch_to_light_theme: 'Activer le thème clair', switch_to_system_theme: 'Utiliser le thème du système', search: 'Rechercher', + clear: 'Effacer', + search_back: 'Retour aux résultats de recherche', search_or_ask: 'Rechercher', search_input_placeholder: 'Rechercher dans le contenu', search_ask_input_placeholder: 'Rechercher ou poser une question', @@ -16,10 +20,15 @@ export const fr = { search_section_result_title: 'Section avec titre ${1}', search_section_result_content: 'Section avec contenu ${1}', search_section_result_default: 'Section', - search_scope_current: '${1}', - search_scope_all: 'Partout', - search_scope_include_all_title: 'Inclure toutes les variantes', - search_scope_include_all_description: 'Rechercher le contenu dans toutes les langues/versions', + search_scope_title: 'Filtre', + search_scope_section_current: '${1}', + search_scope_section_current_description: 'Seulement la section actuelle', + search_scope_section_all: 'Toute la documentation', + search_scope_section_all_description: 'Toutes les sections de la documentation', + search_scope_variant_current: '${1}', + search_scope_variant_current_description: 'Seulement la variante actuelle', + search_scope_variant_all: 'Tout le contenu', + search_scope_variant_all_description: 'Chaque variante disponible', ask: 'Poser une question', search_ask: 'Demander « ${1} »', search_ask_description: 'Trouvez la réponse avec ${1}', @@ -43,6 +52,8 @@ export const fr = { annotation_button_label: 'Afficher l’annotation', code_copied: 'Copié !', code_copy: 'Copier', + code_block_collapsed: 'Afficher les ${1} lignes', + code_block_expanded: 'Afficher moins', table_of_contents_button_label: 'Afficher le sommaire', cookies_title: 'Cookies', cookies_prompt: @@ -73,6 +84,7 @@ export const fr = { link_tooltip_email: 'Envoyer un e-mail à', link_tooltip_page_anchor: 'Aller à la section', open_in_new_tab: 'Ouvrir dans un nouvel onglet', + ai_answer: 'Réponse IA', ai_chat_assistant_name: 'Assistant GitBook', ai_chat_assistant_name_unbranded: 'Assistant IA', ai_chat_assistant_description: 'Je suis là pour vous aider avec la documentation.', diff --git a/packages/gitbook/src/intl/translations/it.ts b/packages/gitbook/src/intl/translations/it.ts index e22c8ff07..704697a2d 100644 --- a/packages/gitbook/src/intl/translations/it.ts +++ b/packages/gitbook/src/intl/translations/it.ts @@ -2,12 +2,16 @@ import type { TranslationLanguage } from './types'; export const it: TranslationLanguage = { locale: 'it', + language: 'Italiano', + flag: '🇮🇹', powered_by_gitbook: 'Offerto da GitBook', sponsored_via_gitbook: 'Sponsorizzato tramite GitBook', switch_to_dark_theme: 'Passa al tema scuro', switch_to_light_theme: 'Passa al tema chiaro', switch_to_system_theme: 'Passa al tema di sistema', search: 'Cerca', + clear: 'Cancella', + search_back: 'Torna ai risultati di ricerca', search_or_ask: 'Chiedi o cerca', search_input_placeholder: 'Cerca contenuti', search_ask_input_placeholder: 'Cerca contenuti o fai una domanda', @@ -18,10 +22,15 @@ export const it: TranslationLanguage = { search_section_result_title: 'Sezione con titolo ${1}', search_section_result_content: 'Sezione con contenuto ${1}', search_section_result_default: 'Sezione', - search_scope_current: '${1}', - search_scope_all: 'Ovunque', - search_scope_include_all_title: 'Includi tutte le varianti', - search_scope_include_all_description: 'Cerca contenuti in ogni lingua/versione', + search_scope_title: 'Filtro', + search_scope_section_current: '${1}', + search_scope_section_current_description: 'Solo la sezione corrente', + search_scope_section_all: 'Tutta la documentazione', + search_scope_section_all_description: 'Tutte le sezioni della documentazione', + search_scope_variant_current: '${1}', + search_scope_variant_current_description: 'Solo la variante corrente', + search_scope_variant_all: 'Tutti i contenuti', + search_scope_variant_all_description: 'Ogni variante disponibile', ask: 'Chiedi', search_ask: 'Chiedi "${1}"', search_ask_description: 'Trova la risposta con ${1}', @@ -46,6 +55,8 @@ export const it: TranslationLanguage = { annotation_button_label: 'Apri annotazione', code_copied: 'Copiato!', code_copy: 'Copia', + code_block_collapsed: 'Mostra tutte le ${1} righe', + code_block_expanded: 'Mostra meno', table_of_contents_button_label: 'Apri indice dei contenuti', cookies_title: 'Cookie', cookies_prompt: @@ -77,6 +88,7 @@ export const it: TranslationLanguage = { link_tooltip_email: "Invia un'email a", link_tooltip_page_anchor: 'Vai alla sezione', open_in_new_tab: 'Apri in una nuova scheda', + ai_answer: 'Risposta AI', ai_chat_assistant_name: 'Assistente GitBook', ai_chat_assistant_name_unbranded: 'Assistente AI', ai_chat_assistant_description: 'Sono qui per aiutarti con la documentazione.', diff --git a/packages/gitbook/src/intl/translations/ja.ts b/packages/gitbook/src/intl/translations/ja.ts index 0bcf32b68..0c32e8769 100644 --- a/packages/gitbook/src/intl/translations/ja.ts +++ b/packages/gitbook/src/intl/translations/ja.ts @@ -2,12 +2,16 @@ import type { TranslationLanguage } from './types'; export const ja: TranslationLanguage = { locale: 'ja', + language: '日本語', + flag: '🇯🇵', powered_by_gitbook: 'GitBook提供', sponsored_via_gitbook: 'GitBookスポンサー', switch_to_dark_theme: 'ダークテーマに切り替え', switch_to_light_theme: 'ライトテーマに切り替え', switch_to_system_theme: 'システムのテーマに切り替え', search: '検索', + clear: 'クリア', + search_back: '検索結果に戻る', search_or_ask: '質問または検索', search_input_placeholder: 'コンテンツを検索', search_ask_input_placeholder: 'コンテンツを検索するか質問をする', @@ -18,10 +22,15 @@ export const ja: TranslationLanguage = { search_section_result_title: 'タイトル「${1}」のセクション', search_section_result_content: 'コンテンツ「${1}」のセクション', search_section_result_default: 'セクション', - search_scope_current: '${1}', - search_scope_all: 'どこでも', - search_scope_include_all_title: 'すべてのバリエーションを含める', - search_scope_include_all_description: 'すべての言語/バージョンでコンテンツを検索', + search_scope_title: 'フィルター', + search_scope_section_current: '${1}', + search_scope_section_current_description: '現在のセクションのみ', + search_scope_section_all: 'ドキュメント全体', + search_scope_section_all_description: 'ドキュメントのすべてのセクション', + search_scope_variant_current: '${1}', + search_scope_variant_current_description: '現在のバリエーションのみ', + search_scope_variant_all: 'すべてのコンテンツ', + search_scope_variant_all_description: '利用可能なすべてのバリエーション', ask: '質問する', search_ask: '"${1}" を質問する', search_ask_description: '${1}で答えを見つける', @@ -46,6 +55,8 @@ export const ja: TranslationLanguage = { annotation_button_label: '注釈を開く', code_copied: 'コピーしました!', code_copy: 'コピー', + code_block_collapsed: 'すべての${1}行を表示', + code_block_expanded: '折りたたむ', table_of_contents_button_label: '目次を開く', cookies_title: 'クッキー', cookies_prompt: @@ -76,6 +87,7 @@ export const ja: TranslationLanguage = { link_tooltip_email: 'メールを送信', link_tooltip_page_anchor: 'ページ内リンク先', open_in_new_tab: '新しいタブで開く', + ai_answer: 'AI回答', ai_chat_assistant_name: 'GitBookアシスタント', ai_chat_assistant_name_unbranded: 'AIアシスタント', ai_chat_assistant_description: 'ドキュメントについてお手伝いします。', diff --git a/packages/gitbook/src/intl/translations/nl.ts b/packages/gitbook/src/intl/translations/nl.ts index 0ec2fec4c..77147647f 100644 --- a/packages/gitbook/src/intl/translations/nl.ts +++ b/packages/gitbook/src/intl/translations/nl.ts @@ -2,12 +2,16 @@ import type { TranslationLanguage } from './types'; export const nl: TranslationLanguage = { locale: 'nl', + language: 'Nederlands', + flag: '🇳🇱', powered_by_gitbook: 'Powered by GitBook', sponsored_via_gitbook: 'Gesponsord door GitBook', switch_to_dark_theme: 'Schakel over naar donkere modus', switch_to_light_theme: 'Schakel over naar lichte modus', switch_to_system_theme: 'Schakel over naar systeemmodus', search: 'Zoeken', + clear: 'Wissen', + search_back: 'Terug naar zoekresultaten', search_or_ask: 'Zoek of vraag', search_input_placeholder: 'Zoek inhoud', search_ask_input_placeholder: 'Zoek inhoud of stel een vraag', @@ -18,10 +22,15 @@ export const nl: TranslationLanguage = { search_section_result_title: 'Sectie met titel ${1}', search_section_result_content: 'Sectie met inhoud ${1}', search_section_result_default: 'Sectie', - search_scope_current: '${1}', - search_scope_all: 'Overal', - search_scope_include_all_title: 'Alle varianten opnemen', - search_scope_include_all_description: 'Zoek inhoud in elke taal/versie', + search_scope_title: 'Filter', + search_scope_section_current: '${1}', + search_scope_section_current_description: 'Alleen de huidige sectie', + search_scope_section_all: 'Alle documentatie', + search_scope_section_all_description: 'Alle secties van de documentatie', + search_scope_variant_current: '${1}', + search_scope_variant_current_description: 'Alleen de huidige variant', + search_scope_variant_all: 'Alle inhoud', + search_scope_variant_all_description: 'Elke beschikbare variant', ask: 'Vragen', search_ask: 'Vraag "${1}"', search_ask_description: 'Vind het antwoord met ${1}', @@ -46,6 +55,8 @@ export const nl: TranslationLanguage = { annotation_button_label: 'Open annotatie', code_copied: 'Gekopieerd!', code_copy: 'Kopiëren', + code_block_collapsed: 'Toon alle ${1} regels', + code_block_expanded: 'Toon minder', table_of_contents_button_label: 'Open inhoudsopgave', cookies_title: 'Cookies', cookies_prompt: @@ -76,6 +87,7 @@ export const nl: TranslationLanguage = { link_tooltip_email: 'E-mail versturen naar', link_tooltip_page_anchor: 'Spring naar sectie', open_in_new_tab: 'Open in nieuw tabblad', + ai_answer: 'AI-antwoord', ai_chat_assistant_name: 'GitBook Assistent', ai_chat_assistant_name_unbranded: 'AI Assistent', ai_chat_assistant_description: 'Ik help je met de documentatie.', diff --git a/packages/gitbook/src/intl/translations/no.ts b/packages/gitbook/src/intl/translations/no.ts index 441ef05a5..a067599f3 100644 --- a/packages/gitbook/src/intl/translations/no.ts +++ b/packages/gitbook/src/intl/translations/no.ts @@ -2,12 +2,16 @@ import type { TranslationLanguage } from './types'; export const no: TranslationLanguage = { locale: 'no', + language: 'Norsk', + flag: '🇳🇴', powered_by_gitbook: 'Drevet av GitBook', sponsored_via_gitbook: 'Sponset av GitBook', switch_to_dark_theme: 'Bytt til mørkt tema', switch_to_light_theme: 'Bytt til lyst tema', switch_to_system_theme: 'Bytt til systemtema', search: 'Søk', + clear: 'Tøm', + search_back: 'Tilbake til søkeresultater', search_or_ask: 'Spør eller søk', search_input_placeholder: 'Søk i innhold', search_ask_input_placeholder: 'Søk i innhold eller still et spørsmål', @@ -18,10 +22,15 @@ export const no: TranslationLanguage = { search_section_result_title: 'Seksjon med tittel ${1}', search_section_result_content: 'Seksjon med innhold ${1}', search_section_result_default: 'Seksjon', - search_scope_current: '${1}', - search_scope_all: 'Overalt', - search_scope_include_all_title: 'Inkluder alle varianter', - search_scope_include_all_description: 'Søk innhold på alle språk/versjoner', + search_scope_title: 'Filter', + search_scope_section_current: '${1}', + search_scope_section_current_description: 'Kun den nåværende seksjonen', + search_scope_section_all: 'Hele dokumentasjonen', + search_scope_section_all_description: 'Alle seksjoner i dokumentasjonen', + search_scope_variant_current: '${1}', + search_scope_variant_current_description: 'Kun den nåværende varianten', + search_scope_variant_all: 'Alt innhold', + search_scope_variant_all_description: 'Hver tilgjengelig variant', ask: 'Spør', search_ask: 'Spør "${1}"', search_ask_description: 'Finn svaret med ${1}', @@ -46,6 +55,8 @@ export const no: TranslationLanguage = { annotation_button_label: 'Åpne merknad', code_copied: 'Kopiert!', code_copy: 'Kopier', + code_block_collapsed: 'Vis alle ${1} linjer', + code_block_expanded: 'Vis færre', table_of_contents_button_label: 'Åpne innholdsfortegnelse', cookies_title: 'Informasjonskapsler', cookies_prompt: @@ -77,6 +88,7 @@ export const no: TranslationLanguage = { link_tooltip_email: 'Send e-post til', link_tooltip_page_anchor: 'Hopp til seksjon', open_in_new_tab: 'Åpne i ny fane', + ai_answer: 'AI-svar', ai_chat_assistant_name: 'GitBook-assistent', ai_chat_assistant_name_unbranded: 'AI-assistent', ai_chat_assistant_description: 'Jeg er her for å hjelpe deg med docs.', diff --git a/packages/gitbook/src/intl/translations/pt-br.ts b/packages/gitbook/src/intl/translations/pt-br.ts index 5b13ddf0c..66c478b8d 100644 --- a/packages/gitbook/src/intl/translations/pt-br.ts +++ b/packages/gitbook/src/intl/translations/pt-br.ts @@ -1,11 +1,15 @@ export const pt_br = { locale: 'pt-br', + language: 'Português (Brasil)', + flag: '🇧🇷', powered_by_gitbook: 'Fornecido por GitBook', sponsored_via_gitbook: 'Patrocinado por GitBook', switch_to_dark_theme: 'Mudar para modo escuro', switch_to_light_theme: 'Mudar para modo claro', switch_to_system_theme: 'Mudar para configuração do sistema', search: 'Busca', + clear: 'Limpar', + search_back: 'Voltar aos resultados da busca', search_or_ask: 'Perguntar ou buscar', search_input_placeholder: 'Buscar conteúdo', search_ask_input_placeholder: 'Buscar conteúdo ou fazer uma pergunta', @@ -16,10 +20,15 @@ export const pt_br = { search_section_result_title: 'Seção com título ${1}', search_section_result_content: 'Seção com conteúdo ${1}', search_section_result_default: 'Seção', - search_scope_current: '${1}', - search_scope_all: 'Em todos os lugares', - search_scope_include_all_title: 'Incluir todas as variantes', - search_scope_include_all_description: 'Buscar conteúdo em todos os idiomas/versões', + search_scope_title: 'Filtro', + search_scope_section_current: '${1}', + search_scope_section_current_description: 'Apenas a seção atual', + search_scope_section_all: 'Toda a documentação', + search_scope_section_all_description: 'Todas as seções da documentação', + search_scope_variant_current: '${1}', + search_scope_variant_current_description: 'Apenas a variante atual', + search_scope_variant_all: 'Todo o conteúdo', + search_scope_variant_all_description: 'Cada variante disponível', ask: 'Perguntar', search_ask: 'Perguntar "${1}"', search_ask_description: 'Encontre a resposta com ${1}', @@ -44,6 +53,8 @@ export const pt_br = { annotation_button_label: 'Abrir anotação', code_copied: 'Copiado!', code_copy: 'Copiar', + code_block_collapsed: 'Mostrar todas as ${1} linhas', + code_block_expanded: 'Mostrar menos', table_of_contents_button_label: 'Abrir o índice', cookies_title: 'Cookies', cookies_prompt: @@ -76,6 +87,7 @@ export const pt_br = { link_tooltip_email: 'Enviar e-mail para', link_tooltip_page_anchor: 'Pular para a seção', open_in_new_tab: 'Abrir em uma nova guia', + ai_answer: 'Resposta de IA', ai_chat_assistant_name: 'Assistente do GitBook', ai_chat_assistant_name_unbranded: 'Assistente de IA', ai_chat_assistant_description: 'Estou aqui para ajudá-lo com a documentação.', diff --git a/packages/gitbook/src/intl/translations/ru.ts b/packages/gitbook/src/intl/translations/ru.ts index 1246f19d2..583d60e12 100644 --- a/packages/gitbook/src/intl/translations/ru.ts +++ b/packages/gitbook/src/intl/translations/ru.ts @@ -1,11 +1,15 @@ export const ru = { locale: 'ru', + language: 'Русский', + flag: '🇷🇺', powered_by_gitbook: 'Работает на GitBook', sponsored_via_gitbook: 'Спонсируется GitBook', switch_to_dark_theme: 'Переключиться на тёмную тему', switch_to_light_theme: 'Переключиться на светлую тему', switch_to_system_theme: 'Переключиться на системную тему', search: 'Поиск', + clear: 'Очистить', + search_back: 'Вернуться к результатам поиска', search_or_ask: 'Найти или спросить', search_input_placeholder: 'Поиск по содержимому', search_ask_input_placeholder: 'Найти в содержимом или задать вопрос', @@ -16,10 +20,15 @@ export const ru = { search_section_result_title: 'Раздел с заголовком ${1}', search_section_result_content: 'Раздел с контентом ${1}', search_section_result_default: 'Раздел', - search_scope_current: '${1}', - search_scope_all: 'Везде', - search_scope_include_all_title: 'Включить все варианты', - search_scope_include_all_description: 'Искать контент на всех языках/версиях', + search_scope_title: 'Фильтр', + search_scope_section_current: '${1}', + search_scope_section_current_description: 'Только текущий раздел', + search_scope_section_all: 'Вся документация', + search_scope_section_all_description: 'Все разделы документации', + search_scope_variant_current: '${1}', + search_scope_variant_current_description: 'Только текущий вариант', + search_scope_variant_all: 'Весь контент', + search_scope_variant_all_description: 'Каждый доступный вариант', ask: 'Спросить', search_ask: 'Спросить "${1}"', search_ask_description: 'Найти ответ с помощью ${1}', @@ -44,6 +53,8 @@ export const ru = { annotation_button_label: 'Открыть аннотацию', code_copied: 'Скопировано!', code_copy: 'Копировать', + code_block_collapsed: 'Показать все ${1} строк', + code_block_expanded: 'Показать меньше', table_of_contents_button_label: 'Открыть оглавление', cookies_title: 'Файлы cookies', cookies_prompt: @@ -75,6 +86,7 @@ export const ru = { link_tooltip_email: 'Отправить письмо на', link_tooltip_page_anchor: 'Перейти к разделу', open_in_new_tab: 'Открыть в новой вкладке', + ai_answer: 'ИИ-ответ', ai_chat_assistant_name: 'GitBook-помощник', ai_chat_assistant_name_unbranded: 'ИИ-помощник', ai_chat_assistant_description: 'Я здесь, чтобы помочь вам с базой знаний.', diff --git a/packages/gitbook/src/intl/translations/zh.ts b/packages/gitbook/src/intl/translations/zh.ts index af572171b..a6ad3baf5 100644 --- a/packages/gitbook/src/intl/translations/zh.ts +++ b/packages/gitbook/src/intl/translations/zh.ts @@ -2,12 +2,16 @@ import type { TranslationLanguage } from './types'; export const zh: TranslationLanguage = { locale: 'zh', + language: '中文', + flag: '🇨🇳', powered_by_gitbook: '由 GitBook 提供支持', sponsored_via_gitbook: '通过 GitBook 赞助', switch_to_dark_theme: '切换到深色主题', switch_to_light_theme: '切换到浅色主题', switch_to_system_theme: '切换到系统主题', search: '搜索', + clear: '清除', + search_back: '返回搜索结果', search_or_ask: '询问或搜索', search_input_placeholder: '搜索内容', search_ask_input_placeholder: '搜索内容或提问', @@ -18,10 +22,15 @@ export const zh: TranslationLanguage = { search_section_result_title: '标题为"${1}"的章节', search_section_result_content: '内容为"${1}"的章节', search_section_result_default: '章节', - search_scope_current: '${1}', - search_scope_all: '任何地方', - search_scope_include_all_title: '包含所有变体', - search_scope_include_all_description: '在所有语言/版本中搜索内容', + search_scope_title: '过滤器', + search_scope_section_current: '${1}', + search_scope_section_current_description: '仅当前章节', + search_scope_section_all: '整个文档', + search_scope_section_all_description: '文档的所有章节', + search_scope_variant_current: '${1}', + search_scope_variant_current_description: '仅当前变体', + search_scope_variant_all: '所有内容', + search_scope_variant_all_description: '每个可用的变体', ask: '询问', search_ask: '询问"${1}"', search_ask_description: '利用${1}找到答案', @@ -45,6 +54,8 @@ export const zh: TranslationLanguage = { annotation_button_label: '打开批注', code_copied: '已复制!', code_copy: '复制', + code_block_collapsed: '显示全部 ${1} 行', + code_block_expanded: '收起', table_of_contents_button_label: '打开目录', cookies_title: 'Cookies', cookies_prompt: '本站使用 cookie 来提供服务并分析流量。浏览本站,即表示您接受${1}。', @@ -74,6 +85,7 @@ export const zh: TranslationLanguage = { link_tooltip_email: '发送邮件到', link_tooltip_page_anchor: '跳转到页面', open_in_new_tab: '在新标签页中打开', + ai_answer: 'AI 回答', ai_chat_assistant_name: 'GitBook 助手', ai_chat_assistant_name_unbranded: 'AI 助手', ai_chat_assistant_description: '我在这里帮助您了解文档。', diff --git a/packages/gitbook/src/lib/context.ts b/packages/gitbook/src/lib/context.ts index 44147fa88..66732de50 100644 --- a/packages/gitbook/src/lib/context.ts +++ b/packages/gitbook/src/lib/context.ts @@ -50,6 +50,13 @@ export type SiteURLData = Pick< * Identifier used for image resizing. */ imagesContextId: string; + + /** + * Whether this request is a fallback rendering. + * We use this when switching variant as we don't know if the page exists in the other variant. + * By knowing it's a fallback, we can redirect to the space base path instead of returning a 404. + */ + isFallback?: boolean; }; /** @@ -127,6 +134,9 @@ export type GitBookSiteContext = GitBookSpaceContext & { /** Context ID used by adaptive content. It represents an unique identifier for the authentication context */ contextId?: string; + + /** Whether this request is a fallback rendering. */ + isFallback: boolean; }; /** @@ -205,6 +215,7 @@ export async function fetchSiteContextByURLLookup( changeRequest: data.changeRequest, revision: data.revision, contextId: data.contextId, + isFallback: data.isFallback ?? false, }); } @@ -223,6 +234,7 @@ export async function fetchSiteContextByIds( changeRequest: string | undefined; revision: string | undefined; contextId?: string; + isFallback: boolean; } ): Promise { const { dataFetcher } = baseContext; @@ -321,6 +333,7 @@ export async function fetchSiteContextByIds( sections, scripts, contextId: ids.contextId, + isFallback: ids.isFallback, }; } diff --git a/packages/gitbook/src/lib/references.tsx b/packages/gitbook/src/lib/references.tsx index b6abe6083..7bf4c2508 100644 --- a/packages/gitbook/src/lib/references.tsx +++ b/packages/gitbook/src/lib/references.tsx @@ -116,7 +116,7 @@ export async function resolveContentRef( case 'anchor': case 'page': { if (contentRef.space && contentRef.space !== space.id) { - return resolveContentRefInSpace(contentRef.space, context, contentRef); + return resolveContentRefInSpace(contentRef.space, context, contentRef, options); } const resolvePageResult = @@ -171,14 +171,15 @@ export async function resolveContentRef( } } else { const parentPage = (resolvePageResult?.ancestors || []).slice(-1).pop(); - // When the looked up ref was a page group we use the page group title as resolved ref text. - // Otherwise use the resolved page title. - text = + // When the looked up ref was a page group we use the page group to resolve title and icon. + // Otherwise use the resolved page title and icon. + const pageOrGroup = parentPage && contentRef.page === parentPage.id && parentPage.type === 'group' - ? parentPage.title - : page.title; + ? parentPage + : page; + text = pageOrGroup.title; emoji = isCurrentPage ? undefined : page.emoji; - icon = ; + icon = ; } return { @@ -345,7 +346,8 @@ async function getBestTargetSpace( async function resolveContentRefInSpace( spaceId: string, context: GitBookAnyContext, - contentRef: ContentRef + contentRef: ContentRef, + options: ResolveContentRefOptions = {} ) { const ctx = await createContextForSpace(spaceId, context); @@ -353,17 +355,35 @@ async function resolveContentRefInSpace( return null; } - const resolved = await resolveContentRef(contentRef, ctx.spaceContext); + const resolved = await resolveContentRef(contentRef, ctx.spaceContext, options); if (!resolved) { return null; } + // Prefer the variant title when available, then the section title, then fallback to the space title. + const ancestorLabel = (() => { + if ('site' in context) { + const foundSiteSpace = findSiteSpaceBy( + context.structure, + (siteSpace) => siteSpace.space.id === spaceId + ); + + return ( + foundSiteSpace?.siteSpace.title ?? + foundSiteSpace?.siteSection?.title ?? + ctx.spaceContext.space.title + ); + } + + return ctx.spaceContext.space.title; + })(); + return { ...resolved, ancestors: [ { - label: ctx.spaceContext.space.title, + label: ancestorLabel, href: ctx.baseURL.toString(), }, ...(resolved.ancestors ?? []), diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index caa19c542..915f2cc70 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -298,6 +298,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { apiToken: siteURLData.apiToken, imagesContextId: imagesContextId, contextId: siteURLData.contextId, + isFallback: requestURL.searchParams.get('fallback') === 'true' ? true : undefined, }; const requestHeaders = new Headers(request.headers); @@ -382,7 +383,11 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { ].join('/'); const rewrittenURL = new URL(`/${route}`, request.nextUrl.toString()); - rewrittenURL.search = request.nextUrl.search; // Preserve the original search params + // Preserve the original search params but remove fallback=true if present + rewrittenURL.search = request.nextUrl.search; + if (rewrittenURL.searchParams.has('fallback')) { + rewrittenURL.searchParams.delete('fallback'); + } const response = NextResponse.rewrite(rewrittenURL, { request: { diff --git a/packages/gitbook/tailwind.config.ts b/packages/gitbook/tailwind.config.ts index c6b075ef2..3ffcc0da7 100644 --- a/packages/gitbook/tailwind.config.ts +++ b/packages/gitbook/tailwind.config.ts @@ -513,6 +513,15 @@ const config: Config = { translate: '100% 0', }, }, + orbit: { + '0%': { + transform: 'rotate(0deg) translateX(10%) rotate(0deg) translateX(-10%)', + }, + '100%': { + transform: + 'rotate(360deg) translateX(10%) rotate(-360deg) translateX(-10%)', + }, + }, }, boxShadow: { thinbottom: '0px 1px 0px rgba(0, 0, 0, 0.05)', @@ -534,6 +543,7 @@ const config: Config = { xl: '1280px', '2xl': '1536px', '3xl': '1920px', + '4xl': '2144px', }, }, plugins: [ @@ -553,7 +563,7 @@ const config: Config = { * Variant when the Table of Content navigation is open. */ addVariant('navigation-open', 'body.navigation-open &'); - addVariant('chat-open', 'body:has(.ai-chat) &'); + addVariant('chat-open', 'body:has(.ai-chat:not(.hidden)) &'); /** * Variant when a header is displayed. @@ -569,6 +579,8 @@ const config: Config = { 'html:not(.announcement-hidden):has(#announcement-banner) &' ); + addVariant('hydrated', 'html.hydrated:not(.route-change) &'); + // Variant to target first-of-type in a column addVariant('column-first-of-type', '.group\\/column > &:first-of-type'); // optional for group-based variants diff --git a/packages/gitbook/wrangler.jsonc b/packages/gitbook/wrangler.jsonc index 6d9c2b232..23178cb65 100644 --- a/packages/gitbook/wrangler.jsonc +++ b/packages/gitbook/wrangler.jsonc @@ -11,9 +11,6 @@ "directory": ".open-next/assets", "binding": "ASSETS" }, - "observability": { - "enabled": true - }, "vars": { "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true", "IS_PREVIEW": "false" diff --git a/packages/react-contentkit/src/ElementWebframe.tsx b/packages/react-contentkit/src/ElementWebframe.tsx index 8dfedbfa0..e0474087a 100644 --- a/packages/react-contentkit/src/ElementWebframe.tsx +++ b/packages/react-contentkit/src/ElementWebframe.tsx @@ -2,13 +2,14 @@ import type { ContentKitWebFrame } from '@gitbook/api'; import React from 'react'; -import { useResizeObserver } from 'usehooks-ts'; import { Icon } from '@gitbook/icons'; import { useContentKitClientContext } from './context'; import { resolveDynamicBinding } from './dynamic'; import type { ContentKitClientElementProps } from './types'; +const MIN_HEIGHT = 32; // minimum height for the iframe in pixels + export function ElementWebframe(props: ContentKitClientElementProps) { const { element } = props; @@ -159,21 +160,22 @@ export function ElementWebframe(props: ContentKitClientElementProps (size.height ?? 0) - ? Math.min(Math.round(liveWidth / aspectRatio), size.height ?? 32) - : 'auto'; + const aspectRatio = + size.aspectRatio || + element.aspectRatio || + (iframeRef.current?.clientWidth && height + ? iframeRef.current.clientWidth / height + : undefined); if (!mounted) { return ; } + // only use height measurement if no aspect ratio is defined + const useHeightMeasurement = !aspectRatio && height; + return (