Compare commits

..

2 Commits

Author SHA1 Message Date
Brett Jephson 078eacce45 changeset 2026-09-28 22:19:28 +01:00
Brett Jephson 9f168ff93b Resolve a page link by its recorded path when its page is gone
A page or anchor ref whose page ID no longer exists now looks for a page at
the path recorded on the ref, so a link keeps working after its target is
deleted and re-created at the same path. The ID still wins while it exists,
so moves and renames resolve as before. The API records the path on refs it
writes; until @gitbook/api ships the field, it is read untyped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rt9YwdSUu7gHehYbEkvEJ8
2026-09-28 22:19:18 +01:00
162 changed files with 696 additions and 2942 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Stop the `?ask` query briefly appearing in the search field before an integration assistant opens.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Track which Ask AI entry point opened the chat in ask view analytics.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Recolor text over a background page cover in the first frame after hydration.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Update API
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix table of contents scrolling back to the current page when expanding another group.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep links working when their target page is deleted and re-created at the same path, by falling back to the path recorded on the link.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Automatically resolve GitHub and GitLab page links to matching pages in the same published site, including cross-space links imported before their target page was available.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Keep the docs embed open when switching section, variant or language.
-6
View File
@@ -1,6 +0,0 @@
---
"@gitbook/embed": minor
"gitbook": patch
---
Add `defaultTab` and `defaultPage` options to the Docs Embed to choose where it opens. `defaultTab` opens the embed on the assistant, search or docs tab. `defaultPage` sets the page the docs tab opens on in place of the site's home page, and the embed opens on that page unless `defaultTab` says otherwise. Both work with the standalone script's `configure`, `frame.configure` and `<GitBookFrame>` props.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix page cover image jumping on load
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Use the DocumentTextColor type defined in API schema.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix low-contrast button labels inside success, warning and danger hints.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Agents can now send a goal with any question they ask on the Markdown version of a page.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix inline Ask AI inputs and buttons doing nothing before search is opened.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Track server-side Insights events (markdown, llms.txt, RSS, ask) with the site's public URL instead of the proxy URL.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Send the previous page's URL with site insights events so broken links can be traced to the page linking to them.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix Loom videos staying blank on pages with many Loom embeds by lazy-loading them.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Improve dark theme contrast for Mermaid edge labels.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Improve the prompt for agents to ask questions.
-6
View File
@@ -1,6 +0,0 @@
---
"@gitbook/react-openapi": patch
"gitbook": patch
---
Fix OpenAPI schemas that reference themselves through a nullable union rendering forever when expanded, which crashed PDF exports.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Use the page's tag title, when set, for the HTML `<title>` of published pages.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix previewed customization and theme overrides being lost when navigating between pages inside the site preview. The preview cookie was scoped to the site's canonical base path instead of the preview route it is actually served under, so the browser dropped it on the next in-preview navigation.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Show expression values in site previews that use a visitor segment.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Clarify the not-found page on sites with login-protected content: prompt anonymous visitors to log in, and tell logged-in visitors they may not have access.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Scroll the table of contents to the active page after client-side navigation.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Show site UI in French or Spanish for spaces set to Canadian French, Mexican Spanish or Latin American Spanish.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Restore the Next.js dev-mode OOM patch that was silently dropped by the 16.3.8 upgrade.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Let search buttons open search limited to the current space.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Send the visitor country to resolvePublishedContentByUrl on dev, preview, staging and selected sites, using the forwarded `x-gitbook-country` header on revalidation requests.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Render a single Ask AI paragraph button that moves to the hovered paragraph, instead of one hidden button per paragraph.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix the first item of a sidebar page group sometimes appearing cut off after navigating.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Skip rendering the Ask AI paragraph button inside table cells, where it is hidden, to keep pages with large tables within memory limits.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Point the "Powered by GitBook" trademark link to gitbook.com/powered-by.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Upgrade object-identity to preserve value types when caching object arguments.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Read the production hostnames that receive the visitor country from the `GITBOOK_VISITOR_COUNTRY_HOSTNAMES` build-time env variable.
@@ -1,6 +0,0 @@
---
"@gitbook/react-contentkit": patch
"gitbook": patch
---
Reserve the webframe's size in the server-rendered layout so anchor links to content below integration blocks no longer land too high.
+70 -46
View File
@@ -1,89 +1,113 @@
# Contributing to GitBook
# Welcome to GitBook's contributing guide!
> _For help, support and product questions, head to our [GitHub Community](https://github.com/orgs/GitbookIO/discussions)._
> _For help, support, feature requests, and product questions - head to our [GitHub Community](https://github.com/orgs/GitbookIO/discussions) 🤖_
Thanks for helping make GitBook better. This repository holds the open source renderer behind every published GitBook site, and the team reads everything that comes in here.
Thank you for investing your time in contributing to GitBook. Any contribution you make will be reviewed by our team. In this guide, you'll learn the different ways you can contribute.
## Ways to contribute
## Types of Contributions
- **Open an issue.** Report a bug, suggest a feature, or tell us when something feels off. This is the most useful thing you can do.
- **Join the conversation.** Share ideas and help others in [GitHub Community](https://github.com/orgs/GitbookIO/discussions).
- **Build with GitBook.** Publish your docs, try new features early, and tell us what works and what doesn't.
This repository contains code related to the rendering engine of GitBook's published content. Depending on what you'd like to contribute to, head to the section below to find the necessary steps.
## Issues instead of pull requests
### Add a feature
Pull requests are limited to the GitBook team. If you've found a bug or want something to change, please [open an issue](https://github.com/GitbookIO/gitbook/issues/new/choose) instead.
Because this portion of GitBook is open source and available for you to use - if you think you can provide extra value through a new feature - you're welcome to add it! If you plan to distribute the code, keep the source code public to comply with GNU GPLv3. To clone in a private repository, you must first acquire a [commercial license](https://www.gitbook.com/pricing).
This isn't because we don't want your help. Every change here ships to every published GitBook site, so we need to own each one from start to finish. A clear issue often helps us more than a pull request: it tells us what's wrong and why, and lets us fix it in a way that fits the rest of the codebase.
### Create a new issue
A good issue includes:
If you spot a problem within a repository, [search if an issue already exists](https://docs.github.com/en/github/searching-for-information-on-github/searching-on-github/searching-issues-and-pull-requests#search-by-the-title-body-or-comments). If a related issue doesn't exist, you can open a new issue here!Please make sure any added issues are
- What you expected to happen, and what happened instead
- A link to a published GitBook site that shows the problem, if you have one
- Steps to reproduce it, plus screenshots or a recording for anything visual
- Your browser and OS, if it looks like it might matter
- Descriptive
- Thoughtful
- Organized
Before opening one, [search existing issues](https://github.com/GitbookIO/gitbook/issues). If someone has already reported it, add a 👍 or any extra details you have.
We recommend adding as many relevant links, minimal reproductions of the issue, and other materials that will help our team solve the issue fast.
Already fixed it in your fork? Link the branch or commit in your issue. We may open a pull request from it, and your commits keep your name.
### Solve an issue
If you open a pull request anyway, we'll close it and point you here. Nothing personal, and we'd still love the issue.
If you're interested in solving an issue in our repository, start by scanning through it's exisiting issues to find one that you're interested in working on. If you find an issue to work on, you are welcome to open a PR with a fix. See the following sections below for more information on contributing for specific sections.
## Reach out
### Documentation
Not sure if it's a bug? Have a half-formed idea? Open an issue or start a [discussion](https://github.com/orgs/GitbookIO/discussions) anyway. We'd much rather hear from you than not, and no report is too small.
The official documentation on GitBook open can be found directly in this Readme. Any updates or changes you would like to make, you can make directly to the README of this repository.
Spotted a wrong or missing translation? Open an issue with the string and the wording you'd suggest.
## Contributing
## Using AI
### Make changes locally
You're welcome to use AI to help you write an issue. Please read and check what it produces before you post: reports that are long, vague or unverified take time away from fixing real problems, and we may close them without a reply.
Any contribution you make can be made to the code located in this repository. In order to contribute, you'll need to start off of a local version of this repository.
## Running locally
#### 1. Fork the repository
You don't need a local setup to open an issue, but it can help you reproduce a problem or self-host the renderer.
##### Using GitHub Desktop:
### Prerequisites
- [Getting started with GitHub Desktop](https://docs.github.com/en/desktop/installing-and-configuring-github-desktop/getting-started-with-github-desktop) will guide you through setting up Desktop.
- Once Desktop is set up, you can use it to [fork the repo](https://docs.github.com/en/desktop/contributing-and-collaborating-using-github-desktop/cloning-and-forking-repositories-from-github-desktop)!
- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`)
- [Bun](https://bun.sh/) (see "packageManager" in `package.json`)
##### Using the command line:
### Setup
- [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them.
1. Use the project's version of Node:
#### 2. Create a working branch and start with your changes
After forking this repository, you'll want to [create a branch](https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-a-branch-for-an-issue) to work off of.
#### 3. Install dependencies and run the project locally
##### Prerequisites:
- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`)
- [Bun](https://bun.sh/) (see "packageManager" in `package.json`)
##### Setup steps:
1. Ensure you are using the project's version of Node:
```bash
nvm use
```
2. Install dependencies:
2. Install dependencies using Bun:
```bash
bun install
```
3. Start the development server:
```bash
bun dev
```
Other commands:
Additional development commands:
- `bun lint`: Lint the code using Oxlint
- `bun lint:fix`: Automatically fix lint issues using Oxlint
- `bun format`: Format the code using Oxfmt
- `bun format:check`: Check formatting without changing files
- `bun typecheck`: Run TypeScript type checking
- `bun unit`: Run unit tests
- `bun e2e`: Run end-to-end tests
- `bun lint`: Lint the code using Oxlint
- `bun lint:fix`: Automatically fix lint issues using Oxlint
- `bun format`: Format the code using Oxfmt
- `bun format:check`: Check formatting without changing files
- `bun typecheck`: Run TypeScript type checking
- `bun unit`: Run unit tests
- `bun e2e`: Run end-to-end tests
#### 4. Preview your changes
### Preview a site
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/url`.
With the development server running, any published GitBook site can be rendered through your local version at `http://localhost:3000/url/<published-url>`. For example, visit `http://localhost:3000/url/gitbook.com/docs` to see our own docs.
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/url/gitbook.com/docs` after running the development server.
The site must be [published publicly](https://gitbook.com/docs/published-documentation/publish-your-content-as-a-docs-site) to render locally.
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://gitbook.com/docs/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
## License
### Commit your update
The renderer is distributed under the [GNU GPLv3 License](https://github.com/GitbookIO/gitbook/blob/main/LICENSE). If you distribute the code, you must keep the source code public. To use it in a private repository, you need a [commercial license](https://www.gitbook.com/pricing).
[Commit your changes](https://github.com/git-guides/git-commit) once you are happy with them. See [Atom's contributing guide](https://github.com/atom/atom/blob/master/CONTRIBUTING.md#git-commit-messages) to know how to use emoji for commit messages!
Once your changes are ready, don't forget to self-review your code to double check that your chagnes are ready to be added.
### Pull Request
When you're finished with the changes, [create a pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request), also known as a PR.
- Don't forget to [link PR to issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) if you are solving one.
- Enable the checkbox to [allow maintainer edits](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork) so the branch can be updated for a merge. Once you submit your PR, a GitBook team member will review your proposal. We may ask questions or request for additional information.
- We may ask for changes to be made before a PR can be merged, either using [suggested changes](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/incorporating-feedback-in-your-pull-request) or pull request comments. You can apply suggested changes directly through the UI. You can make any other changes in your fork, then commit them to your branch.
- As you update your PR and apply changes, mark each conversation as [resolved](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/commenting-on-a-pull-request#resolving-conversations).
- If you run into any merge issues, checkout this [git tutorial](https://lab.github.com/githubtraining/managing-merge-conflicts) to help you resolve merge conflicts and other issues.
### Your PR is merged
Congratulations 🎉 Thank you for your contribution! Once your PR is merged, your contributions will be publicly visible on the relevant repository.
@@ -78,11 +78,6 @@ runs:
echo "GITBOOK_DISABLE_INSIGHTS=true" >> .vercel/.env.${{ inputs.environment }}.local
echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---"
cat .vercel/.env.${{ inputs.environment }}.local
- name: Inject visitor country hostnames
if: ${{ env.GITBOOK_VISITOR_COUNTRY_HOSTNAMES != '' }}
shell: bash
run: |
echo "GITBOOK_VISITOR_COUNTRY_HOSTNAMES=$GITBOOK_VISITOR_COUNTRY_HOSTNAMES" >> .vercel/.env.${{ inputs.environment }}.local
- name: Inject staging build env vars
if: ${{ inputs.environment == 'staging' }}
shell: bash
-4
View File
@@ -10,8 +10,6 @@ jobs:
environment:
name: 2v-production
url: ${{ steps.deploy.outputs.deployment-url }}
env:
GITBOOK_VISITOR_COUNTRY_HOSTNAMES: ${{ vars.GITBOOK_VISITOR_COUNTRY_HOSTNAMES }}
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
steps:
@@ -33,8 +31,6 @@ jobs:
environment:
name: 2c-production
url: ${{ steps.deploy.outputs.deployment-url }}
env:
GITBOOK_VISITOR_COUNTRY_HOSTNAMES: ${{ vars.GITBOOK_VISITOR_COUNTRY_HOSTNAMES }}
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
steps:
+5 -5
View File
@@ -85,7 +85,7 @@ GitBook Open uses fontawesome. For self-hosting and local development, for licen
## Contributing
GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn how to report bugs and suggest changes.
GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
### Types of contributions
@@ -93,22 +93,22 @@ We encourage you to contribute to GitBook to help us build the best tool for doc
#### Translations
The GitBook UI is rendered using a set of translation files found in [`packages/gitbook/src/intl/translations`](/packages/gitbook/src/intl/translations/). Spotted a wrong or missing translation? Open an issue and let us know.
The GitBook UI is rendered using a set of translation files found in [`packages/gitbook/src/intl/translations`](/packages/gitbook/src/intl/translations/). We welcome all additional translations for the UI.
#### Bugs
Encounter a bug? [Open an issue](https://github.com/GitbookIO/gitbook/issues/new/choose) with as much detail as you can. Clear reports help us fix things faster for everyone.
Encounter a bug or find an issue you'd like to fix? Helping us fix issues related to GitBook greatly improves the experience for everyone. Head to the issues section of this repository to learn more about the types of bugs you can already help out with.
## Deployment
> [!WARNING]
> While it is possible to self-host this project, we do not recommend this unless you are certain this option fits your need.
>
> _Looking for a specific feature in GitBook? Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to suggest it._
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to get started._
>
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintenance, or updates to forked and self-hosted instances of this project.
>
> We want to make it as easy as possible for our community to collaborate and push the future of GitBook, which is why we encourage you to share your ideas with us instead of creating your own version.
> We want to make it as easy as possible for our community to collaborate and push the future of GitBook, which is why we encourage you to contribute to our product directly instead of creating your own version.
This project allows you to self-host the rendering portion of your GitBook published content. Self-hosting has pros and cons.
+23 -20
View File
@@ -129,8 +129,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.1.7",
"@opennextjs/cloudflare": "1.20.8",
"@opennextjs/aws": "4.1.3",
"@opennextjs/cloudflare": "1.20.5",
"@panzoom/panzoom": "^4.6.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -160,11 +160,11 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.3.8",
"next": "^16.3.6",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
"object-identity": "^0.2.1",
"object-identity": "^0.1.2",
"openapi-types": "^12.1.3",
"p-map": "^7.0.3",
"p-retry": "^8.0.0",
@@ -342,10 +342,11 @@
},
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"next@16.3.8": "patches/next@16.3.8.patch",
"next@16.3.6": "patches/next@16.3.6.patch",
},
"overrides": {
"@codemirror/state": "6.4.1",
"@opennextjs/aws": "4.1.5",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"axios": "1.8.4",
@@ -355,7 +356,7 @@
},
"catalog": {
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.206.0",
"@gitbook/api": "0.202.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -727,7 +728,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
"@gitbook/api": ["@gitbook/api@0.206.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ctgnDSQ26ZeZvdU/Zb1xxU97yMquKnhGiA0QblAIzKUmfi/3aYmWYHAwXwvEg0wtBrEtrjzme9pXgX/ACWnANA=="],
"@gitbook/api": ["@gitbook/api@0.202.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-qhrjEQbNNmCljR0AgP79+BsVh9yelh9TIBg9X3TyYx5a3lJwDiT3sw1ND9DFQSxGHp8hH5w+20KJHn/IDpTbaw=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -891,23 +892,23 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
"@next/env": ["@next/env@16.3.8", "", {}, "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw=="],
"@next/env": ["@next/env@16.3.6", "", {}, "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg=="],
"@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="],
@@ -943,9 +944,9 @@
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.7", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.27 <16 || >=16.3.8" }, "bin": { "open-next": "dist/index.js" } }, "sha512-ELezSARrTfxp/76cz41eQjki1AV0CuBUKOznU3w/tOk7i87x867RkkYho+gDkog9gpnGzefHdJu463o9VaabNQ=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.5", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3" }, "bin": { "open-next": "dist/index.js" } }, "sha512-41LNGtS5R5SCfn9zFxHx9QKJIRnmf1uZH+jonHkUMlRKER0bSmUSGjK6GJF7e50XdLXFpTHzaqgRvGmzvr9Btw=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.8", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.7", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.27 <16 || >=16.3.8", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-7rrsBqd234GBBnHITobS0gagT4/fSfHJwm+ogYTyUb7ubg3PGC06biJgL9l4DZabFeu/ucqvml80sBr5torBgg=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.5", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.3", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-Y4qCnHTYMa8waEHvf7iqDJuiresuREp0zithSZ8hT33Wn9WyvOUMBM0SWNH961Wv7ynHRdxAJ0ggmnQ3aSdzdg=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -2649,7 +2650,7 @@
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
"next": ["next@16.3.8", "", { "dependencies": { "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.8", "@next/swc-darwin-x64": "16.3.8", "@next/swc-linux-arm64-gnu": "16.3.8", "@next/swc-linux-arm64-musl": "16.3.8", "@next/swc-linux-x64-gnu": "16.3.8", "@next/swc-linux-x64-musl": "16.3.8", "@next/swc-win32-arm64-msvc": "16.3.8", "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw=="],
"next": ["next@16.3.6", "", { "dependencies": { "@next/env": "16.3.6", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.6", "@next/swc-darwin-x64": "16.3.6", "@next/swc-linux-arm64-gnu": "16.3.6", "@next/swc-linux-arm64-musl": "16.3.6", "@next/swc-linux-x64-gnu": "16.3.6", "@next/swc-linux-x64-musl": "16.3.6", "@next/swc-win32-arm64-msvc": "16.3.6", "@next/swc-win32-x64-msvc": "16.3.6", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
@@ -2677,7 +2678,7 @@
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"object-identity": ["object-identity@0.2.1", "", {}, "sha512-itX2N4m0IvaLhFZMi9YkW4nFML6FshKCCpnn6iKJZVIkCNM/ZRaG4t1bzD2ncaW4r6cHJa59XY+7oZAH7tg8tA=="],
"object-identity": ["object-identity@0.1.2", "", {}, "sha512-Px5puVllX5L2aBjbcfXpiG5xXeq6OE8RckryTeP2Zq+0PgYrCGJXmC6LblWgknKSJs11Je2W4U2NOWFj3t/QXQ=="],
"object-inspect": ["object-inspect@1.13.2", "", {}, "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g=="],
@@ -2933,6 +2934,8 @@
"rolldown-plugin-dts": ["rolldown-plugin-dts@0.16.11", "", { "dependencies": { "@babel/generator": "^7.28.3", "@babel/parser": "^7.28.4", "@babel/types": "^7.28.4", "ast-kit": "^2.1.2", "birpc": "^2.6.1", "debug": "^4.4.3", "dts-resolver": "^2.1.2", "get-tsconfig": "^4.10.1", "magic-string": "^0.30.19" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-beta.9", "typescript": "^5.0.0", "vue-tsc": "~3.1.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-9IQDaPvPqTx3RjG2eQCK5GYZITo203BxKunGI80AGYicu1ySFTUyugicAaTZWRzFWh9DSnzkgNeMNbDWBbSs0w=="],
"rollup": ["rollup@3.29.5", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w=="],
"roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="],
"router": ["router@2.0.0", "", { "dependencies": { "array-flatten": "3.0.0", "is-promise": "4.0.0", "methods": "~1.1.2", "parseurl": "~1.3.3", "path-to-regexp": "^8.0.0", "setprototypeof": "1.2.0", "utils-merge": "1.0.1" } }, "sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ=="],
+4 -3
View File
@@ -19,7 +19,8 @@
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.27.3",
"axios": "1.8.4"
"axios": "1.8.4",
"@opennextjs/aws": "4.1.5"
},
"private": true,
"scripts": {
@@ -48,7 +49,7 @@
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.206.0",
"@gitbook/api": "0.202.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -67,6 +68,6 @@
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
"next@16.3.8": "patches/next@16.3.8.patch"
"next@16.3.6": "patches/next@16.3.6.patch"
}
}
-30
View File
@@ -54,7 +54,6 @@ GitBook('configure', {
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
},
tabs: ['assistant', 'search', 'docs'],
defaultPage: '/getting-started',
actions: [
{
icon: 'circle-question',
@@ -108,7 +107,6 @@ frame.clearChat();
// Configure the embed (see Configuration section for all options)
frame.configure({
tabs: ['assistant', 'search', 'docs'],
defaultPage: '/getting-started',
actions: [
{
icon: 'circle-question',
@@ -144,7 +142,6 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
}}
tabs={['assistant', 'search', 'docs']}
defaultPage="/getting-started"
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
assistantName="Support Assistant"
suggestions={['What is GitBook?', 'How do I get started?']}
@@ -252,33 +249,6 @@ Override which tabs are displayed. Defaults to your site's configuration.
tabs: ['assistant', 'search', 'docs']
```
### `defaultTab`
Available in: Standalone script, NPM package, React components
The tab the embed opens on. Without it, the embed opens on `defaultPage` if set, otherwise on the assistant (or the docs when the assistant isn't available). The tab must be one of the enabled `tabs`.
- **Type**: `'assistant' | 'search' | 'docs'`
```javascript
tabs: ['assistant', 'search', 'docs'],
defaultTab: 'search'
```
`defaultTab` and `defaultPage` apply when the embed first loads: the standalone widget keeps its place when it is closed and reopened. To move it later, use `navigateToPage` or `navigateToAssistant`.
### `defaultPage`
Available in: Standalone script, NPM package, React components
The page the docs tab opens on, in place of your site's home page. Accepts the same references as `navigateToPage`: the page's path within the site, an absolute path, or its full published URL. Unless `defaultTab` says otherwise, the embed opens on this page.
- **Type**: `string`
```javascript
defaultPage: '/getting-started/quickstart'
```
### `closeButton`
Available in: Standalone script, NPM package, React components
-6
View File
@@ -62,12 +62,6 @@ export type GitBookEmbeddableConfiguration = {
/** Tabs to display in the embed (if enabled on the site). */
tabs: ('assistant' | 'docs' | 'search')[];
/** Tab to open the embed on. */
defaultTab?: 'assistant' | 'docs' | 'search';
/** Page to open the docs tab on, instead of the site's home page. */
defaultPage?: string;
/** Additional buttons to be displayed in the header of the GitBook embed. */
actions: GitBookEmbeddableActionDefinition[];
@@ -27,8 +27,6 @@ export function GitBookFrame(props: GitBookFrameProps) {
suggestions = [],
tools = [],
tabs = ['assistant', 'search', 'docs'],
defaultTab,
defaultPage,
trademark = true,
closeButton = false,
assistantName,
@@ -52,8 +50,6 @@ export function GitBookFrame(props: GitBookFrameProps) {
useEffect(() => {
gitbookFrame?.configure({
tabs,
defaultTab,
defaultPage,
actions,
greeting,
suggestions,
@@ -69,8 +65,6 @@ export function GitBookFrame(props: GitBookFrameProps) {
suggestions,
tools,
tabs,
defaultTab,
defaultPage,
closeButton,
trademark,
assistantName,
-2
View File
@@ -95,8 +95,6 @@ const nextConfig = {
GITBOOK_BLOCK_SEARCH_INDEXATION: process.env.GITBOOK_BLOCK_SEARCH_INDEXATION,
GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: process.env.GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE,
GITBOOK_DISABLE_INSIGHTS: process.env.GITBOOK_DISABLE_INSIGHTS,
GITBOOK_VISITOR_COUNTRY_HOSTNAMES: process.env.GITBOOK_VISITOR_COUNTRY_HOSTNAMES,
VERCEL_TARGET_ENV: process.env.VERCEL_TARGET_ENV,
// Next.js envs
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
+4 -4
View File
@@ -20,8 +20,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.1.7",
"@opennextjs/cloudflare": "1.20.8",
"@opennextjs/aws": "4.1.3",
"@opennextjs/cloudflare": "1.20.5",
"@panzoom/panzoom": "^4.6.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -51,11 +51,11 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.3.8",
"next": "^16.3.6",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
"object-identity": "^0.2.1",
"object-identity": "^0.1.2",
"openapi-types": "^12.1.3",
"p-map": "^7.0.3",
"p-retry": "^8.0.0",
+7 -12
View File
@@ -7,7 +7,7 @@ import type { CustomizationAIMode } from '@gitbook/api';
import type { GitBookAssistant } from '@gitbook/browser-types';
import { Icon, type IconName } from '@gitbook/icons';
import { type AskAITrigger, useAIChatController, useAIChatState } from '.';
import { useAIChatController, useAIChatState } from '.';
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
import { useIntegrationAssistants } from '../Integrations';
import { useSetSearchState } from '../Search/useSearch';
@@ -26,8 +26,7 @@ export type AIConfig = {
};
};
export type Assistant = Omit<GitBookAssistant, 'icon' | 'open'> & {
open: (query?: string, trigger?: AskAITrigger) => void;
export type Assistant = Omit<GitBookAssistant, 'icon'> & {
/**
* Unique identifier for the assistant. Generated automatically using Crypto.randomUUID().
* @example '123e4567-e89b-12d3-a456-426614174000'
@@ -103,8 +102,8 @@ export function useAI(): AIContext {
className="size-text-lg"
/>
),
open: (query?: string, trigger?: AskAITrigger) => {
chatController.open(trigger);
open: (query?: string) => {
chatController.open();
if (query) {
chatController.postMessage({ message: query });
}
@@ -120,13 +119,9 @@ export function useAI(): AIContext {
icon: <AISearchIcon />,
open: (query?: string) => {
if (query) {
setSearchState((prev) => ({
...prev,
query: null,
ask: query,
scope: prev?.scope ?? 'default',
open: true,
}));
setSearchState((prev) =>
prev ? { ...prev, query: null, ask: query, open: true } : null
);
}
},
pageAction: false,
@@ -11,7 +11,6 @@ import {
type AIStreamResponseToolCallPending,
type AIToolCallResult,
SiteInsightsDisplayContext,
type SiteInsightsEventAskView,
} from '@gitbook/api';
import { getInsightsSession, useTrackEvent } from '../Insights';
@@ -61,8 +60,6 @@ export type AIChatStatus =
| 'error'
| 'confirm';
export type AskAITrigger = NonNullable<SiteInsightsEventAskView['trigger']>;
export type AIChatState = {
/**
* If true, the chat is open.
@@ -163,7 +160,7 @@ type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
export type AIChatController = {
/** Open the dialog */
open: (trigger?: AskAITrigger) => void;
open: () => void;
/** Close the dialog */
close: () => void;
/** Post a message to the session */
@@ -305,28 +302,22 @@ export function AIChatProvider(props: {
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
// Open AI chat and sync with search state
const onOpen = React.useCallback(
(trigger?: AskAITrigger) => {
setIntercomLauncherHidden(true);
if (!renderMessageOptions?.asEmbeddable) {
trackEvent({ type: 'ask_view', trigger });
}
const onOpen = React.useCallback(() => {
setIntercomLauncherHidden(true);
const { initialQuery } = globalState.getState();
globalState.setState((state) => ({ ...state, opened: true }));
const { initialQuery } = globalState.getState();
globalState.setState((state) => ({ ...state, opened: true }));
// Update search state to show ask mode with first message or current ask value
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
// Update search state to show ask mode with first message or current ask value
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
notify(eventsRef.current.get('open'), {});
},
[setSearchState, trackEvent, renderMessageOptions?.asEmbeddable]
);
notify(eventsRef.current.get('open'), {});
}, [setSearchState]);
// Close AI chat and clear ask parameter
const onClose = React.useCallback(() => {
@@ -24,6 +24,7 @@ import {
EmbeddableFrameTitle,
} from '../Embeddable/EmbeddableFrame';
import { useIsMounted, useNow } from '../hooks';
import { useTrackEvent } from '../Insights';
import { Button } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
@@ -67,6 +68,16 @@ export function AIChat() {
}
);
// Track the view of the AI chat
const trackEvent = useTrackEvent();
React.useEffect(() => {
if (chat.opened) {
trackEvent({
type: 'ask_view',
});
}
}, [chat.opened, trackEvent]);
return (
<SideSheet
side="right"
@@ -39,7 +39,7 @@ export function AIChatButton(props: {
</div>
}
aria-label={tString(language, 'ai_chat_ask', assistant.label)}
onClick={() => assistant.open(undefined, 'site')}
onClick={() => assistant.open()}
>
{showLabel ? t(language, 'ask') : null}
</Button>
@@ -1,58 +1,29 @@
'use client';
import fnv1a from '@sindresorhus/fnv1a';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { AIChatIcon } from '../AIChatIcon';
import { useAIChatController, useAIConfig } from '@/components/AI';
import { Button } from '@/components/primitives';
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { type ClassValue, tcls } from '@/lib/tailwind';
/**
* A small icon button revealed in the left margin of a paragraph when it is hovered (on devices
* with a fine pointer). Clicking it stages the paragraph's text as a reference and opens the AI
* chat — a more discoverable variant of the text-selection "Ask" affordance.
* A small icon button revealed in the left margin of a top-level paragraph when it is hovered (on
* devices with a fine pointer). Clicking it stages the paragraph's text as a reference and opens
* the AI chat — a more discoverable variant of the text-selection "Ask" affordance.
*
* A single instance is portaled into the last hovered paragraph wrapper, rather than rendering one
* per paragraph, so pages with many paragraphs don't pay for markup that is hidden until hovered.
* Its visibility is still driven purely by the `group/ask-ai` hover state of that wrapper.
* The button is absolutely positioned so it never affects the document flow, and its visibility is
* driven purely by the `group/ask-ai` hover state of its paragraph wrapper.
*/
export function AskAIParagraphButton() {
export function AskAIParagraphButton(props: { content: string; className?: ClassValue }) {
const { content, className } = props;
const config = useAIConfig();
const language = useLanguage();
const chatController = useAIChatController();
const [target, setTarget] = React.useState<HTMLElement | null>(null);
const enabled = isAIChatEnabled(config.aiMode);
React.useEffect(() => {
if (!enabled) {
return;
}
const onPointerOver = (event: PointerEvent) => {
if (!(event.target instanceof Element)) {
return;
}
const wrapper = event.target.closest<HTMLElement>('[data-ask-ai-paragraph]');
if (wrapper) {
setTarget(wrapper);
}
};
document.addEventListener('pointerover', onPointerOver);
return () => document.removeEventListener('pointerover', onPointerOver);
}, [enabled]);
if (!enabled || !target) {
return null;
}
const onClick = () => {
const text = getParagraphText(target).trim();
const text = content.trim();
if (!text) {
return;
}
@@ -62,12 +33,12 @@ export function AskAIParagraphButton() {
id: `text-${fnv1a(text, { size: 32 })}`,
content: text,
});
chatController.open('hover');
chatController.open();
chatController.setDraft(tString(language, 'ai_chat_paragraph_draft'));
chatController.focus();
};
return createPortal(
return (
<div
className={tcls(
// Sit in the left margin, flush against the paragraph so there is no hover gap.
@@ -81,7 +52,13 @@ export function AskAIParagraphButton() {
'invisible opacity-0 transition-opacity duration-150',
'hover:visible hover:opacity-100 group-hover/ask-ai:visible group-hover/ask-ai:opacity-100',
// Never shown on touch / hover-less contexts.
'not-pointer-fine:hidden'
'not-pointer-fine:hidden',
// Hidden where an overflow-clipped ancestor would cut it off (ARIA and native
// tables, record cards).
'in-[[role=table]]:hidden',
'in-[table]:hidden',
'in-[[data-card]]:hidden',
className
)}
>
<Button
@@ -98,37 +75,6 @@ export function AskAIParagraphButton() {
'in-[.hint.bg-danger]:bg-danger in-[.hint.bg-info]:bg-info in-[.hint.bg-success]:bg-success in-[.hint.bg-warning]:bg-warning'
)}
/>
</div>,
target
</div>
);
}
/**
* Read the paragraph's text, skipping rendered math like `getNodeText` does.
*/
function getParagraphText(wrapper: HTMLElement): string {
const paragraph = wrapper.querySelector(':scope > p');
if (!paragraph) {
return '';
}
const walker = document.createTreeWalker(
paragraph,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
{
acceptNode: (node) =>
node instanceof Element &&
node.matches('.katex, mjx-container, [aria-hidden="true"]')
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT,
}
);
let text = '';
while (walker.nextNode()) {
if (walker.currentNode.nodeType === Node.TEXT_NODE) {
text += walker.currentNode.textContent ?? '';
}
}
return text;
}
@@ -84,7 +84,7 @@ export function AskAITextSelection() {
id: `text-${fnv1a(content, { size: 32 })}`,
content,
});
chatController.open('selection');
chatController.open();
chatController.focus();
clear();
};
@@ -247,15 +247,11 @@ async function renderMermaidDiagram(args: {
const { source, id, darkMode, mermaidRuntimeURL } = args;
const { mermaid } = await loadMermaid(mermaidRuntimeURL);
// Mermaid's default dark edge label pill only reaches 4.43:1 contrast, below WCAG AA.
const themeVariables = darkMode ? { edgeLabelBackground: '#3a3a3a' } : undefined;
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
darkMode,
theme: darkMode ? 'dark' : undefined,
themeVariables,
});
const renderContainer = createMermaidRenderContainer();
@@ -37,10 +37,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
<>
<div
dangerouslySetInnerHTML={{
__html:
context.mode !== 'print' && shouldLazyLoad(block.data.url)
? lazyLoadIframes(embed.html)
: embed.html,
__html: embed.html,
}}
data-visual-test="blackout"
/>
@@ -76,23 +73,6 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
);
}
/**
* Pages with many Loom embeds exhaust the browser's request budget
* (ERR_INSUFFICIENT_RESOURCES) when every player loads at once, leaving some blank.
*/
function lazyLoadIframes(html: string): string {
return html.replace(/<iframe\b(?![^>]*\bloading=)/gi, '<iframe loading="lazy"');
}
function shouldLazyLoad(url: string): boolean {
try {
const { hostname } = new URL(url);
return hostname === 'loom.com' || hostname.endsWith('.loom.com');
} catch {
return false;
}
}
/**
* Create an integration block with an unfurl action from the GitBook Embed response.
*/
@@ -135,10 +135,10 @@ const HINT_STYLES: {
header: 'bg-warning-active',
body: [
'text-neutral-strong',
'links-default:[&_a:not(.button)]:text-warning',
'links-default:[&_a:not(.button):hover]:text-warning-strong',
'links-default:[&_a:not(.button)]:decoration-warning/6',
'links-accent:[&_a:not(.button)]:decoration-warning',
'links-default:[&_a]:text-warning',
'links-default:[&_a:hover]:text-warning-strong',
'links-default:[&_a]:decoration-warning/6',
'links-accent:[&_a]:decoration-warning',
'decoration-warning/6',
'[&_.can-override-bg]:bg-warning-active',
'[&_.can-override-text]:text-warning-strong',
@@ -152,10 +152,10 @@ const HINT_STYLES: {
header: 'bg-danger-active',
body: [
'text-neutral-strong',
'links-default:[&_a:not(.button)]:text-danger',
'links-default:[&_a:not(.button):hover]:text-danger-strong',
'links-default:[&_a:not(.button)]:decoration-danger/6',
'links-accent:[&_a:not(.button)]:decoration-danger',
'links-default:[&_a]:text-danger',
'links-default:[&_a:hover]:text-danger-strong',
'links-default:[&_a]:decoration-danger/6',
'links-accent:[&_a]:decoration-danger',
'decoration-danger/6',
'[&_.can-override-bg]:bg-danger-active',
'[&_.can-override-text]:text-danger-strong',
@@ -169,10 +169,10 @@ const HINT_STYLES: {
header: 'bg-success-active',
body: [
'text-neutral-strong',
'links-default:[&_a:not(.button)]:text-success',
'links-default:[&_a:not(.button):hover]:text-success-strong',
'links-default:[&_a:not(.button)]:decoration-success/6',
'links-accent:[&_a:not(.button)]:decoration-success',
'links-default:[&_a]:text-success',
'links-default:[&_a:hover]:text-success-strong',
'links-default:[&_a]:decoration-success/6',
'links-accent:[&_a]:decoration-success',
'decoration-success/6',
'[&_.can-override-bg]:bg-success-active',
'[&_.can-override-text]:text-success-strong',
@@ -13,14 +13,9 @@ const INPUT_SIZE_MAP: Record<NonNullable<ButtonProps['size']>, 'small' | 'medium
};
export function InlineActionButton(
props: {
action: 'ask' | 'search';
query?: string;
/** Where a search runs: the whole site, or only the space the visitor is reading. */
scope?: 'site' | 'space';
} & { buttonProps: ButtonProps } // TODO: Type this properly: Pick<api.DocumentInlineButton, 'action' | 'query'> & { buttonProps: ButtonProps }
props: { action: 'ask' | 'search'; query?: string } & { buttonProps: ButtonProps } // TODO: Type this properly: Pick<api.DocumentInlineButton, 'action' | 'query'> & { buttonProps: ButtonProps }
) {
const { action, query, scope, buttonProps } = props;
const { action, query, buttonProps } = props;
const { assistants } = useAI();
const chatState = useAIChatState();
@@ -34,7 +29,7 @@ export function InlineActionButton(
setSearchState((prev) => ({
...prev,
ask: null,
scope: scope === 'space' ? 'current' : 'default',
scope: 'default',
query: value ?? query,
open: true,
}));
@@ -47,11 +47,6 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
<InlineActionButton
action={inline.data.action.action}
query={inline.data.action.query ?? ''}
scope={
inline.data.action.action === 'search'
? inline.data.action.scope
: undefined
}
buttonProps={buttonProps}
/>
);
@@ -52,8 +52,8 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const anchorElement = (
<InlineLinkAnchor
href={resolved.href}
contentRef={resolved.resolvedRef ?? inline.data.ref}
isExternal={(resolved.resolvedRef ?? inline.data.ref).kind === 'url'}
contentRef={inline.data.ref}
isExternal={inline.data.ref.kind === 'url'}
>
{inlinesElement}
</InlineLinkAnchor>
@@ -121,7 +121,7 @@ function InlineLinkTooltipWrapper(props: {
let breadcrumbs = resolved.ancestors ?? [];
const isMailto = resolved.href.startsWith('mailto:');
const isExternal = (resolved.resolvedRef ?? inline.data.ref).kind === 'url';
const isExternal = inline.data.ref.kind === 'url';
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
if (isMailto) {
@@ -52,14 +52,6 @@
@apply relative;
}
.contentkit-webframe-container {
@apply relative;
}
.contentkit-webframe-loading {
@apply absolute inset-0 m-auto size-5 animate-spin;
}
/** Card */
.contentkit-card {
@apply flex flex-col border rounded border-tint-subtle;
@@ -4,11 +4,12 @@ import { CustomizationAIMode } from '@gitbook/api';
import type { BlockProps } from './Block';
import { Inlines } from './Inlines';
import { getTextAlignment } from './utils';
import { AskAIParagraphButton } from '@/components/AIChat/AskAIParagraphButton';
import { getNodeText } from '@/lib/document';
import { tcls } from '@/lib/tailwind';
export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
const { block, style, ancestorBlocks, ...contextProps } = props;
const { block, style, ...contextProps } = props;
const { context } = contextProps;
const paragraph = (
@@ -27,8 +28,6 @@ export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
const contentContext = context.contentContext;
const aiAssistantEnabled =
context.mode !== 'print' &&
// Table cells would clip the margin button, and big tables hold thousands of paragraphs.
!ancestorBlocks.some((ancestor) => ancestor.type === 'table') &&
contentContext != null &&
'customization' in contentContext &&
contentContext.customization.ai.mode === CustomizationAIMode.Assistant;
@@ -39,8 +38,6 @@ export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
// `self-center`/`self-end`) — otherwise centered paragraphs pin left on wide/no-TOC pages.
return (
<div
// The shared `AskAIParagraphButton` portals itself into the hovered wrapper.
data-ask-ai-paragraph=""
className={tcls(
'group/ask-ai relative',
style,
@@ -48,6 +45,7 @@ export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
)}
>
{paragraph}
<AskAIParagraphButton content={text} />
</div>
);
}
@@ -50,7 +50,6 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
tag: Tag = 'div',
ariaLabelledBy,
block,
ancestorBlocks,
document,
record,
column,
@@ -152,7 +151,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
<Blocks
tag={Tag}
document={document}
ancestorBlocks={[...ancestorBlocks, block]}
ancestorBlocks={[]}
nodes={fragment.nodes}
style={[
'blocks',
@@ -117,8 +117,8 @@ function Color(props: MarkedLeafProps<DocumentMarkColor>) {
return (
<span
className={tcls([
textColorToStyle[mark.data.text ?? 'default'],
backgroundColorToStyle[mark.data.background ?? 'default'],
textColorToStyle[mark.data.text],
backgroundColorToStyle[mark.data.background],
])}
>
{children}
@@ -1,6 +1,6 @@
import { expect, it } from 'bun:test';
import type { DocumentBlockParagraph, DocumentBlockTable, JSONDocument } from '@gitbook/api';
import type { DocumentBlockParagraph, JSONDocument } from '@gitbook/api';
import { isBlockOffscreen } from './isBlockOffscreen';
@@ -42,31 +42,3 @@ it('should return true if block is offscreen', () => {
)
).toBe(true);
});
it('should treat content in table cells as offscreen', () => {
const cell: DocumentBlockParagraph = {
object: 'block',
type: 'paragraph',
nodes: [],
};
const table: DocumentBlockTable = {
object: 'block',
type: 'table',
isVoid: true,
data: {
view: { type: 'cards', cardSize: 'medium', columns: ['column'] },
records: {},
definition: {},
},
fragments: [{ object: 'fragment', fragment: 'cell', nodes: [cell] }],
};
const document: JSONDocument = {
object: 'document',
nodes: [table],
data: {},
};
expect(isBlockOffscreen({ document, block: cell, ancestorBlocks: [table] })).toBe(true);
});
@@ -18,11 +18,6 @@ export function isBlockOffscreen(
},
screenHeight = 500
): boolean {
// Cell content lives in fragments, not table nodes, so its row position can't be estimated.
if (ancestorBlocks.some((ancestor) => ancestor.type === 'table')) {
return true;
}
let current = block;
const allAncestors = [document, ...ancestorBlocks];
@@ -61,7 +61,9 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
}
trackEvent(
{ type: 'ask_view', trigger: 'embed' },
{
type: 'ask_view',
},
{
pageId: null,
displayContext: api.SiteInsightsDisplayContext.Embed,
@@ -97,7 +97,6 @@ export async function EmbeddableDocsPage(
) ?? context.siteSpace
}
siteSpaces={variants.translations}
asEmbeddable
className="my-1.5 ml-2 self-start"
/>
) : null}
@@ -126,7 +125,6 @@ export async function EmbeddableDocsPage(
) ?? context.siteSpace
}
siteSpaces={variants.translations}
asEmbeddable
className="max-md:[&_.button-content]:block"
/>
) : null
@@ -139,7 +137,6 @@ export async function EmbeddableDocsPage(
context={context}
siteSpace={context.siteSpace}
siteSpaces={variants.generic}
asEmbeddable
className="w-full px-3"
/>
) : null}
@@ -9,7 +9,6 @@ import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitb
import { integrationsAssistantTools } from '../Integrations';
import { Button, LinkContext, type LinkContextType } from '../primitives';
import { getChannel } from './channel';
import { DEFAULT_EMBEDDABLE_TABS, getEmbeddableLandingTab } from './getEmbeddableLandingTab';
import { resolveEmbedPageLink } from './server-actions';
import { useAI, useAIChatController } from '@/components/AI';
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
@@ -24,17 +23,6 @@ const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() =
trademark: true,
}));
const docsHome = createStore<{ reference?: string; href?: string }>(() => ({}));
// Module-level so it survives the layout remounting on a cross-space navigation.
let landed = false;
function resolvePageHref(pagePath: string, baseURL: string): Promise<string> {
return resolveEmbedPageLink(pagePath)
.then((resolved) => ('href' in resolved ? resolved.href : `${baseURL}/page/${pagePath}`))
.catch(() => `${baseURL}/page/${pagePath}`);
}
// oxlint-disable-next-line typescript/no-explicit-any
function log(...data: any[]) {
// oxlint-disable-next-line no-console
@@ -78,15 +66,6 @@ export function EmbeddableIframeAPI(props: { baseURL: string }) {
const { baseURL, router, chatController } = refs.current;
const message = payload as ParentToFrameMessage;
const navigate = (href: Promise<string>) => {
const token = ++navToken.current;
href.then((href) => {
if (navToken.current === token) {
refs.current.router.push(href);
}
});
};
log('[gitbook] received message', message);
switch (message.type) {
@@ -104,43 +83,29 @@ export function EmbeddableIframeAPI(props: { baseURL: string }) {
break;
}
case 'configure': {
const { settings } = message;
embeddableConfiguration.setState(settings);
embeddableConfiguration.setState(message.settings);
integrationsAssistantTools.setState({
tools: settings.tools,
tools: message.settings.tools,
});
const defaultPage =
typeof settings.defaultPage === 'string' && settings.defaultPage
? settings.defaultPage
: undefined;
let docsHomeHref: Promise<string> | undefined;
if (defaultPage !== docsHome.getState().reference) {
docsHome.setState({ reference: defaultPage, href: undefined });
if (defaultPage) {
docsHomeHref = resolvePageHref(defaultPage, baseURL);
docsHomeHref.then((href) => {
if (docsHome.getState().reference === defaultPage) {
docsHome.setState({ href });
}
});
}
}
if (!landed) {
landed = true;
const tab = getEmbeddableLandingTab(settings);
if (tab === 'docs') {
navigate(docsHomeHref ?? Promise.resolve(`${baseURL}/page/`));
} else if (tab) {
navToken.current++;
router.push(`${baseURL}/${tab}`);
}
}
break;
}
case 'navigateToPage': {
navigate(resolvePageHref(message.pagePath, baseURL));
// Resolve the target server-side: on a multi-space site the page may
// live in another space/section, whose base must go before
// `~gitbook/embed/page`. Fall back to a same-space push on failure.
const { pagePath } = message;
const token = ++navToken.current;
// Ignore the result if a later navigation has since superseded this one.
const push = (href: string) => {
if (navToken.current === token) {
router.push(href);
}
};
resolveEmbedPageLink(pagePath)
.then((resolved) =>
push('href' in resolved ? resolved.href : `${baseURL}/page/${pagePath}`)
)
.catch(() => push(`${baseURL}/page/${pagePath}`));
break;
}
case 'navigateToAssistant': {
@@ -167,7 +132,7 @@ export function useEmbeddableConfiguration<T = GitBookEmbeddableConfiguration>(
export function useEmbeddableTabs() {
const configuredTabs = useEmbeddableConfiguration((state) => state.tabs);
return configuredTabs.length > 0 ? configuredTabs : DEFAULT_EMBEDDABLE_TABS;
return configuredTabs.length > 0 ? configuredTabs : ['assistant', 'search', 'docs'];
}
export function useEmbeddableLinkContext() {
@@ -235,7 +200,6 @@ export function EmbeddableIframeTabs(props: {
const { ref, active = 'assistant', baseURL, siteTitle, onNavigate } = props;
const actions = useEmbeddableConfiguration((state) => state.actions);
const tabs = useEmbeddableTabs();
const docsHomeHref = useStore(docsHome, (state) => state.href);
const { assistants, config } = useAI();
const language = useLanguage();
@@ -264,7 +228,7 @@ export function EmbeddableIframeTabs(props: {
key: 'docs',
label: siteTitle,
icon: 'book-open',
href: docsHomeHref ?? `${baseURL}/page/`,
href: `${baseURL}/page/`,
}
: null,
].filter((tab) => tab !== null);
@@ -1,8 +1,6 @@
import type { CustomizationDefaultThemeMode } from '@gitbook/api';
import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import { AskAIParagraphButton } from '../AIChat';
import { LoadIntegrations } from '../Integrations';
import { NavigationLoader } from '../primitives/NavigationLoader';
import { SpaceLayoutServerContext } from '../SpaceLayout';
import { Trademark } from '../TableOfContents/Trademark';
@@ -78,7 +76,6 @@ export async function EmbeddableRootLayout({
}}
>
<NavigationLoader />
<AskAIParagraphButton />
<VisibilityProvider className="fixed inset-0 flex flex-col">
{children}
{context.customization.trademark.enabled ? (
@@ -96,8 +93,6 @@ export async function EmbeddableRootLayout({
/>
</SpaceLayoutServerContext>
</EmbeddableAIContextProvider>
{/* Embeds load no integrations; this marks them loaded so search stops waiting. */}
<LoadIntegrations />
</SiteLayoutClientContexts>
</CustomizationRootLayout>
);
@@ -1,39 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { getEmbeddableLandingTab } from './getEmbeddableLandingTab';
describe('getEmbeddableLandingTab', () => {
it('keeps the embed default when nothing is configured', () => {
expect(getEmbeddableLandingTab({})).toBeNull();
expect(getEmbeddableLandingTab({ tabs: ['docs', 'search'] })).toBeNull();
});
it('opens on the configured tab', () => {
expect(getEmbeddableLandingTab({ defaultTab: 'search' })).toBe('search');
expect(
getEmbeddableLandingTab({ tabs: ['assistant', 'search', 'docs'], defaultTab: 'search' })
).toBe('search');
});
it('opens on the docs when only a default page is set', () => {
expect(getEmbeddableLandingTab({ defaultPage: 'getting-started' })).toBe('docs');
});
it('prefers the configured tab over the default page', () => {
expect(
getEmbeddableLandingTab({ defaultTab: 'assistant', defaultPage: 'getting-started' })
).toBe('assistant');
});
it('ignores a tab that is not enabled', () => {
expect(getEmbeddableLandingTab({ tabs: ['assistant'], defaultTab: 'search' })).toBeNull();
expect(
getEmbeddableLandingTab({ tabs: ['assistant', 'search'], defaultPage: 'quickstart' })
).toBeNull();
});
it('ignores an unknown tab', () => {
// @ts-expect-error - the parent window is plain JS and can send anything.
expect(getEmbeddableLandingTab({ defaultTab: 'settings' })).toBeNull();
});
});
@@ -1,17 +0,0 @@
import type { GitBookEmbeddableConfiguration } from '@gitbook/embed';
export type EmbeddableTab = GitBookEmbeddableConfiguration['tabs'][number];
export const DEFAULT_EMBEDDABLE_TABS: EmbeddableTab[] = ['assistant', 'search', 'docs'];
export function getEmbeddableLandingTab(
settings: Partial<Pick<GitBookEmbeddableConfiguration, 'tabs' | 'defaultTab' | 'defaultPage'>>
): EmbeddableTab | null {
const tab = settings.defaultTab ?? (settings.defaultPage ? 'docs' : undefined);
if (!tab || !DEFAULT_EMBEDDABLE_TABS.includes(tab)) {
return null;
}
const tabs = settings.tabs?.length ? settings.tabs : DEFAULT_EMBEDDABLE_TABS;
return tabs.includes(tab) ? tab : null;
}
@@ -2,10 +2,9 @@ import type { SiteSpace } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import type { ButtonProps } from '../primitives';
import { getTargetURLForSiteSpace } from '../SiteSections/encodeClientSiteSections';
import { SpacesDropdownClient } from './SpacesDropdownClient';
import type { GitBookSiteContext } from '@/lib/context';
import { getLocalizedTitle } from '@/lib/sites';
import { getLocalizedTitle, getSiteSpaceURL } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
// Memoized regex for checking if a string starts with an emoji
@@ -21,18 +20,9 @@ export function SpacesDropdown(
siteSpace: SiteSpace;
siteSpaces: SiteSpace[];
className?: string;
/** Keep the links inside the embed rather than sending readers to the full site. */
asEmbeddable?: boolean;
} & ButtonProps
) {
const {
context,
siteSpace,
siteSpaces,
className,
asEmbeddable = false,
...buttonProps
} = props;
const { context, siteSpace, siteSpaces, className, ...buttonProps } = props;
const currentLanguage = context.locale;
const dropdownClassName = tcls(
@@ -43,7 +33,7 @@ export function SpacesDropdown(
const slimSpaces = siteSpaces.map((siteSp) => ({
id: siteSp.id,
title: getLocalizedTitle(siteSp, currentLanguage),
url: getTargetURLForSiteSpace(context, siteSp, asEmbeddable),
url: getSiteSpaceURL(context, siteSp),
isActive: siteSp.id === siteSpace.id,
spaceId: siteSp.space.id,
}));
@@ -67,10 +57,9 @@ export function TranslationsDropdown(
siteSpace: SiteSpace;
siteSpaces: SiteSpace[];
className?: string;
asEmbeddable?: boolean;
} & ButtonProps
) {
const { context, siteSpace, siteSpaces, className, asEmbeddable, ...buttonProps } = props;
const { context, siteSpace, siteSpaces, className, ...buttonProps } = props;
const title = getLocalizedTitle(siteSpace, context.locale);
const hasEmojiPrefix = startsWithEmoji(title);
@@ -81,7 +70,6 @@ export function TranslationsDropdown(
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
asEmbeddable={asEmbeddable}
variant="blank"
className={tcls(
'bg-transparent lg:max-w-64 max-md:[&_.button-content]:hidden',
@@ -4,7 +4,6 @@ import * as React from 'react';
import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
import type * as api from '@gitbook/api';
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi';
import { type CurrentContentContext, useCurrentContent } from '../hooks';
@@ -76,15 +75,12 @@ export function InsightsProvider(props: InsightsProviderProps) {
[pathname: string]:
| {
url: string;
previousUrl: string | null;
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext?: InsightsEventPageContext;
}
| undefined;
}>({});
// Href of the last page an event was tracked on; `undefined` until the first event.
const lastUrlRef = React.useRef<string | null | undefined>(undefined);
/**
* Synchronously flush all the pending events.
@@ -109,7 +105,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
allEvents.push(
...transformEvents({
url: eventsForPathname.url,
previousUrl: eventsForPathname.previousUrl,
events: eventsForPathname.events,
context: currentContent,
pageContext: eventsForPathname.pageContext,
@@ -159,9 +154,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
) => {
const pathname = window.location.pathname;
const previous = eventsRef.current[pathname];
const lastUrl =
lastUrlRef.current === undefined ? getSameOriginReferrer() : lastUrlRef.current;
lastUrlRef.current = window.location.href;
eventsRef.current[pathname] = {
// An explicitly-provided context wins so page-scoped events (e.g. feedback) can
// attribute to their page even when the pathname's ambient context has none — such
@@ -169,7 +161,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
// context keep the stored one.
pageContext: ctx ?? previous?.pageContext,
url: previous?.url ?? window.location.href,
previousUrl: previous ? previous.previousUrl : lastUrl,
events: [
...(previous?.events ?? []),
{
@@ -223,18 +214,6 @@ export function useTrackEvent(): TrackEventCallback {
return React.useContext(InsightsContext);
}
/**
* The referrer when it's another page of this origin, so a full page load (e.g. an absolute link
* to a missing page) still records the page it came from.
*/
function getSameOriginReferrer(): string | null {
if (document.referrer === window.location.href || !URL.canParse(document.referrer)) {
return null;
}
const referrer = new URL(document.referrer);
return referrer.origin === window.location.origin ? referrer.href : null;
}
/**
* Post the events to the server.
*/
@@ -261,7 +240,6 @@ function sendEvents(args: { eventUrl: string; events: api.SiteInsightsEvent[] })
*/
function transformEvents(input: {
url: string;
previousUrl: string | null;
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext: InsightsEventPageContext;
@@ -280,11 +258,6 @@ function transformEvents(input: {
const location: api.SiteInsightsEventLocation = {
url: input.url,
// The embed's navigation is between its own tabs, not pages of the site.
previousUrl:
input.pageContext.displayContext === SiteInsightsDisplayContext.Embed
? null
: input.previousUrl,
siteSection: input.context.siteSectionId ?? null,
siteSpace: input.context.siteSpaceId ?? null,
space: input.context.spaceId,
@@ -147,10 +147,6 @@ export function useCustomCookieBanner(): CustomCookieBannerStore {
export function LoadIntegrations() {
React.useEffect(() => {
return whenIntegrationsReady(() => {
// Integrations must only receive `load` once, even if this is mounted twice.
if (integrationsStore.getState().loaded) {
return;
}
dispatchGitBookIntegrationEvent('load');
integrationsStore.setState({ loaded: true });
});
@@ -67,7 +67,7 @@ export function ActionOpenAssistant(props: {
href: page.href,
});
}
assistant.open(undefined, 'page');
assistant.open();
}}
/>
);
@@ -1,8 +1,4 @@
import {
type JSONDocument,
type RevisionPageDocument,
SiteInsightsDisplayContext,
} from '@gitbook/api';
import type { JSONDocument, RevisionPageDocument, SiteInsightsDisplayContext } from '@gitbook/api';
import { DocumentView, DocumentViewSkeleton } from '../DocumentView';
import { CurrentPageProvider } from '../hooks/useCurrentPage';
@@ -131,7 +127,6 @@ export async function PageBody(props: {
ancestors={ancestors}
withRSSFeed={contentHasUpdates}
hasAPIBlocks={hasAPIBlocks}
asEmbeddable={insightsDisplayContext === SiteInsightsDisplayContext.Embed}
/>
{document && !isNodeEmpty(document) ? (
<OptionalSuspense
@@ -29,21 +29,18 @@ interface PageCoverImageProps {
export function PageCoverImage(props: PageCoverImageProps) {
const { imgs, y, height, mask } = props;
const { objectPositionY, isLoading } = useCoverPosition(imgs, y, {
height,
aspectRatio: PAGE_COVER_SIZE,
});
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
if (isLoading) {
return (
<div className="h-full w-full overflow-hidden">
<div className="h-full w-full overflow-hidden" ref={containerRef}>
<div className="h-full w-full animate-pulse bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900" />
</div>
);
}
return (
<div className="h-full w-full overflow-hidden @container" style={{ height }}>
<div className="h-full w-full overflow-hidden" ref={containerRef} style={{ height }}>
<img
src={imgs.light.src}
srcSet={imgs.light.srcSet}
@@ -55,7 +52,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
aspectRatio: height
? undefined
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
objectPosition: `50% ${objectPositionY}`,
objectPosition: `50% ${objectPositionY}%`,
height, // if no height is passed, no height will be set.
maskComposite: 'intersect',
maskImage:
@@ -76,7 +73,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
aspectRatio: height
? undefined
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
objectPosition: `50% ${objectPositionY}`,
objectPosition: `50% ${objectPositionY}%`,
height, // if no height is passed, no height will be set.
maskComposite: 'intersect',
maskImage:
@@ -19,17 +19,14 @@ import {
import { PageAsideToggleButton } from '../PageAside/PageAsideButton';
import { PageIcon } from '../PageIcon';
import { getPDFURLSearchParams } from '../PDF';
import {
findBestTargetURL,
getTargetURLForSiteSpace,
} from '../SiteSections/encodeClientSiteSections';
import { findBestTargetURL } from '../SiteSections/encodeClientSiteSections';
import { categorizeVariants } from '../SpaceLayout/categorizeVariants';
import { BreadcrumbItemDropdown, type BreadcrumbSibling } from './BreadcrumbItemDropdown';
import { PageTags } from './PageTags';
import type { GitBookSiteContext, SiteStructureNode } from '@/lib/context';
import { hasAdaptiveMcpEndpoint } from '@/lib/mcp/endpoints';
import { type AncestorRevisionPage, resolveFirstDocument } from '@/lib/pages';
import { getLocalizedTitle } from '@/lib/sites';
import { getLocalizedTitle, getSiteSpaceURL } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { getPageRSSURL } from '@/routes/rss';
@@ -44,10 +41,8 @@ export async function PageHeader(props: {
* exactly as-is.
*/
hasAPIBlocks: boolean;
/** Keep the context crumbs' links inside the embed rather than sending readers to the full site. */
asEmbeddable: boolean;
}) {
const { context, page, ancestors, withRSSFeed, hasAPIBlocks, asEmbeddable } = props;
const { context, page, ancestors, withRSSFeed, hasAPIBlocks } = props;
const { revision, linker } = context;
const hasAncestors = ancestors.length > 0;
@@ -87,10 +82,7 @@ export async function PageHeader(props: {
// Section groups have no URL of their own; only sections are navigable. Resolve the
// section to the site space matching the current variant so switching sections keeps
// the reader on their current variant (same logic as the section tabs).
href:
node.object === 'site-section'
? findBestTargetURL(context, node, asEmbeddable)
: undefined,
href: node.object === 'site-section' ? findBestTargetURL(context, node) : undefined,
label: getLocalizedTitle(node, context.locale),
icon: node.icon,
siblings: siblings
@@ -100,7 +92,7 @@ export async function PageHeader(props: {
// Don't offer hidden sections/groups as switch targets.
.filter((sibling) => !visibleSectionIds || visibleSectionIds.has(sibling.id))
.map((sibling) => {
const href = getSectionNodeURL(context, sibling, asEmbeddable);
const href = getSectionNodeURL(context, sibling);
// Keep siblings whose URL is "" (the site's first page); only drop the ones
// with no resolvable URL at all.
return href !== undefined
@@ -120,7 +112,7 @@ export async function PageHeader(props: {
if (currentSiteSpace) {
contextCrumbs.push({
key: `variant-${currentSiteSpace.id}`,
href: getTargetURLForSiteSpace(context, currentSiteSpace, asEmbeddable),
href: getSiteSpaceURL(context, currentSiteSpace),
label: getLocalizedTitle(currentSiteSpace, context.locale),
siblings: [],
// Reuse the header's variant switcher rather than plain per-variant links, so each entry
@@ -130,7 +122,7 @@ export async function PageHeader(props: {
slimSpaces: variantSpaces.map((siteSpace) => ({
id: siteSpace.id,
title: getLocalizedTitle(siteSpace, context.locale),
url: getTargetURLForSiteSpace(context, siteSpace, asEmbeddable),
url: getSiteSpaceURL(context, siteSpace),
isActive: siteSpace.id === currentSiteSpace.id,
spaceId: siteSpace.space.id,
})),
@@ -456,13 +448,9 @@ function collectSectionNodeIds(list: SiteStructureNode[]): Set<string> {
* The URL resolves to the site space matching the current variant (same logic as the section tabs),
* so switching sections keeps the reader on their current variant.
*/
function getSectionNodeURL(
context: GitBookSiteContext,
node: SectionNode,
asEmbeddable: boolean
): string | undefined {
function getSectionNodeURL(context: GitBookSiteContext, node: SectionNode): string | undefined {
const section = findFirstSection(node);
return section ? findBestTargetURL(context, section, asEmbeddable) : undefined;
return section ? findBestTargetURL(context, section) : undefined;
}
/**
@@ -1,5 +1,6 @@
'use client';
import { useLayoutEffect, useState } from 'react';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useResizeObserver } from 'usehooks-ts';
interface ImageSize {
width: number;
@@ -21,18 +22,19 @@ interface Images {
}
/**
* Hook to compute the CSS object position Y for a cover image, from the y offset and the image
* dimensions. The container must set `container-type: inline-size` and be as wide as the image,
* since the position is computed against its width.
* Hook to calculate the object position Y percentage for a cover image
* based on the y offset, image dimensions, and container dimensions.
*/
export function useCoverPosition(
imgs: Images,
y: number,
container: { height: number | undefined; aspectRatio: ImageSize }
) {
export function useCoverPosition(imgs: Images, y: number) {
const containerRef = useRef<HTMLDivElement>(null);
const [loadedDimensions, setLoadedDimensions] = useState<ImageSize | null>(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.
@@ -66,31 +68,42 @@ export function useCoverPosition(
// 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 {
objectPositionY: imageDimensions
? getCoverObjectPositionY(imageDimensions, y, container)
: '50%',
containerRef,
objectPositionY,
isLoading: !imageDimensions || isLoading,
};
}
/**
* Offset the image `y` natural pixels from centered, clamped so it keeps covering the container.
* Expressed in CSS against the container width (`cqw`), so it renders the same on the server as
* after hydration, without measuring the container.
*/
function getCoverObjectPositionY(
image: ImageSize,
y: number,
container: { height: number | undefined; aspectRatio: ImageSize }
): string {
const containerHeight = container.height
? `${container.height}px`
: `${(100 * container.aspectRatio.height) / container.aspectRatio.width}cqw`;
// Rendered height of the image under `object-fit: cover`.
const scaledHeight = `max(${(100 * image.height) / image.width}cqw, ${containerHeight})`;
const maxOffset = `(${scaledHeight} - ${containerHeight}) / 2`;
const offset = `${scaledHeight} * ${y / image.height}`;
return `calc(50% + clamp(-1 * ${maxOffset}, ${offset}, ${maxOffset}))`;
}
@@ -5,7 +5,6 @@ import React from 'react';
import { useAI } from '../AI';
import { useTrackEvent } from '../Insights';
import { useIntegrationsLoaded } from '../Integrations';
import { useBodyLoaded } from '../primitives';
import {
clearLastSearchQuery,
@@ -141,7 +140,6 @@ export function useSearchController(
const trackEvent = useTrackEvent();
const resultsRef = React.useRef<SearchResultsRef>(null);
const isLoaded = useBodyLoaded();
const integrationsLoaded = useIntegrationsLoaded();
const restoredLastQueryForSiteSpaceRef = React.useRef<string | null>(null);
React.useEffect(() => {
@@ -171,8 +169,6 @@ export function useSearchController(
const withAI = assistants.length > 0;
const withSearchAI = assistants.filter((assistant) => assistant.mode === 'search').length > 0;
// Integration assistants register on window load, so wait for it before treating `ask` as a search query.
const askIsSearchQuery = withSearchAI || (!withAI && integrationsLoaded);
// Handle initial ask state on page load, once assistants are ready.
// `ask=` should still bootstrap the assistant on the docs site, so we must
@@ -207,7 +203,7 @@ export function useSearchController(
const query =
prev?.query ??
getLastSearchQuery(siteSpace.id) ??
(askIsSearchQuery ? prev?.ask : null) ??
(withSearchAI || !withAI ? prev?.ask : null) ??
'';
return {
@@ -221,7 +217,7 @@ export function useSearchController(
trackEvent({
type: 'search_open',
});
}, [state?.open, setSearchState, siteSpace.id, trackEvent, withAI, askIsSearchQuery]);
}, [state?.open, setSearchState, siteSpace.id, trackEvent, withAI, withSearchAI]);
const setQuery = React.useCallback(
(value: string) => {
@@ -265,7 +261,7 @@ export function useSearchController(
});
const searchValue =
state?.query ?? (askIsSearchQuery ? state?.ask : null) ?? lastSearchQuery ?? '';
state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? lastSearchQuery ?? '';
const searchResultsId = `search-results-${React.useId()}`;
// Only clears the remembered last query and stops any in-flight fetch — it must
@@ -82,7 +82,7 @@ type CoverOverlap =
* `data-over-cover="split"` with the crossing point in `--cover-edge`.
*/
function useMarkTextOverCover() {
React.useLayoutEffect(() => {
React.useEffect(() => {
const root = document.documentElement;
const pageCover = document.querySelector<HTMLElement>('[data-gb-page-cover]');
@@ -150,7 +150,7 @@ function useMarkTextOverCover() {
});
};
update();
scheduleUpdate();
window.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate, { passive: true });
@@ -1,49 +0,0 @@
import { describe, expect, it } from 'bun:test';
import type { RevisionPageDocument } from '@gitbook/api';
import { getPageFullTitle } from './title';
import type { GitBookSiteContext } from '@/lib/context';
function makeContext(siteTitle: string, sectionTitle = ''): GitBookSiteContext {
return {
site: { title: siteTitle },
visibleSections: sectionTitle
? {
current: { title: sectionTitle, default: false },
list: [{ object: 'site-section' }, { object: 'site-section' }],
}
: undefined,
visibleSiteSpaces: [],
} as unknown as GitBookSiteContext;
}
function makePage(title: string, tagTitle?: string): RevisionPageDocument {
return { title, tagTitle } as unknown as RevisionPageDocument;
}
describe('getPageFullTitle', () => {
it('uses tagTitle as the first segment', () => {
expect(getPageFullTitle(makeContext('GitBook'), makePage('Page title', 'SEO title'))).toBe(
'SEO title | GitBook'
);
});
it('falls back to the page title when tagTitle is absent', () => {
expect(getPageFullTitle(makeContext('GitBook'), makePage('Page title'))).toBe(
'Page title | GitBook'
);
});
it('deduplicates the section title against tagTitle', () => {
expect(
getPageFullTitle(makeContext('GitBook', 'Section'), makePage('Page title', 'Section'))
).toBe('Section | GitBook');
});
it('deduplicates the site title against tagTitle', () => {
expect(
getPageFullTitle(makeContext('SEO title'), makePage('Page title', 'SEO title'))
).toBe('SEO title');
});
});
@@ -4,6 +4,7 @@ import { notFound, redirect } from 'next/navigation';
import {
CustomizationDefaultThemeMode,
CustomizationHeaderPreset,
type RevisionPageDocument,
SiteInsightsDisplayContext,
type TranslationLanguage,
} from '@gitbook/api';
@@ -17,7 +18,6 @@ import {
getPathnameParam,
} from './fetch';
import { PageClientLayout } from './PageClientLayout';
import { getPageFullTitle } from './title';
import { UpdatesFilterProvider } from '@/components/DocumentView/UpdatesFilter';
import { UpdatesFilterScript } from '@/components/DocumentView/UpdatesFilterScript';
import { PageAside } from '@/components/PageAside';
@@ -33,7 +33,11 @@ import { getResizedImageURL } from '@/lib/images';
import { getPagePath } from '@/lib/pages';
import { resolveContentRef } from '@/lib/references';
import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
import { getSiteSpacePagePaths, resolveSiteSpaceCustomHomePage } from '@/lib/sites';
import {
getSiteSpacePagePaths,
getSiteStructureTitle,
resolveSiteSpaceCustomHomePage,
} from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import {
generateUpdatesFilterCSS,
@@ -41,8 +45,6 @@ import {
updatesFilterStyleHref,
} from '@/lib/updates';
export { getPageFullTitle } from './title';
export type SitePageProps = {
context: GitBookSiteContext;
pageParams: PagePathParams;
@@ -284,11 +286,11 @@ export async function getSitePageData(props: SitePageProps) {
if (pathname !== null) {
// If the pathname was not normalized, redirect to the normalized version
// before trying to resolve the page again
redirect(context.linker.toPathForPagePath({ path: pathname }));
redirect(context.linker.toPathInSpace(pathname));
} else {
// If the page is not found and we are in fallback mode, return a redirect to the basepath
if (context.isFallback) {
redirect(context.linker.toPathForPagePath({ path: '/' }));
redirect(context.linker.toPathInSpace('/'));
}
notFound();
}
@@ -419,3 +421,20 @@ async function resolvePageMetaLinks(
alternates: [],
};
}
/**
* Get the <title> for a page.
*/
export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) {
const { site } = context;
const siteStructureTitle = getSiteStructureTitle(context);
return [
page.title,
// Prevent duplicate titles by comparing against the page title.
page.title !== siteStructureTitle ? siteStructureTitle : null, // The first page of a section is often the same as the section title, so we don't need to show it.
page.title !== site.title ? site.title : null, // The site title can also be the same as the site title on the site's landing page.
]
.filter(Boolean)
.join(' | ');
}
@@ -16,7 +16,6 @@ import { useSetSearchState } from '../Search';
import { fetchSiteIndex } from '../Search/site-index';
import { SiteAuthLoginButton } from '../SiteAuth/SiteAuthLoginLink';
import {
useIsLoggedInVisitor,
useSiteAdaptiveAuthLoginHref,
useSiteIndexURL,
useSpaceBasePath,
@@ -45,9 +44,6 @@ export function SitePageNotFound() {
const setSearchState = useSetSearchState();
const { assistants } = useAI();
const isAuthenticated = useIsLoggedInVisitor();
const copy = getNotFoundCopy({ hasAdaptiveLogin: !!adaptiveAuthLoginHref, isAuthenticated });
// Show the assistant input when a non-search assistant is available (i.e. not just ask-AI).
const assistant = assistants.find((candidate) => candidate.mode !== 'search') ?? null;
const inputLabel = tString(language, assistant ? 'search_or_ask' : 'search');
@@ -128,20 +124,21 @@ export function SitePageNotFound() {
'text-tint-strong'
)}
>
{t(language, copy.title)}
{t(
language,
adaptiveAuthLoginHref
? 'notfound_adaptive_title'
: 'notfound_title'
)}
</h1>
<p className={tcls('text-base', 'text-tint', 'text-center')}>
{t(language, copy.message)}
<p className={tcls('text-base', 'text-tint')}>
{t(
language,
adaptiveAuthLoginHref ? 'notfound_adaptive' : 'notfound'
)}
</p>
</div>
{adaptiveAuthLoginHref && isAuthenticated ? (
// Re-running login refreshes the visitor's claims, e.g. after a role change.
<SiteAuthLoginButton
href={adaptiveAuthLoginHref}
variant="secondary"
label={t(language, 'notfound_adaptive_login_again')}
/>
) : adaptiveAuthLoginHref ? (
{adaptiveAuthLoginHref ? (
<div className="flex flex-col items-center gap-4">
<SiteAuthLoginButton
href={adaptiveAuthLoginHref}
@@ -193,19 +190,6 @@ export function SitePageNotFound() {
);
}
function getNotFoundCopy(args: { hasAdaptiveLogin: boolean; isAuthenticated: boolean }) {
if (!args.hasAdaptiveLogin) {
return { title: 'notfound_title', message: 'notfound' } as const;
}
if (args.isAuthenticated) {
return {
title: 'notfound_adaptive_no_access_title',
message: 'notfound_adaptive_no_access',
} as const;
}
return { title: 'notfound_adaptive_title', message: 'notfound_adaptive' } as const;
}
/**
* List of related pages, with skeleton rows while they load. Renders nothing once we know
* there are no suggestions.
@@ -6,8 +6,7 @@ import type { GitBookSiteContext } from '@/lib/context';
mock.module('server-only', () => ({}));
const { fetchPageData, getLowercasePathnameRedirect, resolveMissingPagePath } =
await import('./fetch');
const { fetchPageData, getLowercasePathnameRedirect } = await import('./fetch');
const { normalizeURL } = await import('@/lib/data/urls');
const page = {
@@ -95,70 +94,6 @@ describe('fetchPageData', () => {
});
});
describe('resolveMissingPagePath', () => {
function createRedirectContext(options: {
siteRedirect?: { target: string; permanent?: boolean };
spaceRedirectPageId?: string;
}) {
const getSiteRedirectBySource = mock(async ({ source }: { source: string }) =>
options.siteRedirect && source === '/old'
? {
data: {
target: options.siteRedirect.target,
redirect: { permanent: options.siteRedirect.permanent ?? false },
},
}
: { error: { code: 404, message: 'Not found' } }
);
const getRevisionPageByPath = mock(async () =>
options.spaceRedirectPageId
? { data: { id: options.spaceRedirectPageId } }
: { error: { code: 404, message: 'Not found' } }
);
return {
organizationId: 'org-1',
site: { id: 'site-1' },
space: { id: 'space-1', revision: 'revision-1' },
revisionId: 'revision-1',
revision: { pages: [page] },
linker: {
toPathInSpace: (path: string) => path,
toRelativePathInSite: (path: string) => path,
toLinkForContent: (url: string) => new URL(url).pathname,
},
dataFetcher: { getSiteRedirectBySource, getRevisionPageByPath },
} as unknown as GitBookSiteContext;
}
it('resolves a site redirect', async () => {
const context = createRedirectContext({
siteRedirect: { target: 'https://docs.example.com/new', permanent: true },
});
expect(await resolveMissingPagePath(context, 'old')).toEqual({
type: 'redirect',
destination: '/new',
permanent: true,
});
});
it('resolves a space redirect to a page', async () => {
const context = createRedirectContext({ spaceRedirectPageId: page.id });
expect(await resolveMissingPagePath(context, 'old')).toEqual({
type: 'page',
page: { page, ancestors: [] },
});
});
it('returns undefined when nothing matches', async () => {
const context = createRedirectContext({});
expect(await resolveMissingPagePath(context, 'old')).toBeUndefined();
});
});
describe('getLowercasePathnameRedirect', () => {
it('redirects ASCII paths with uppercase letters', () => {
expect(getLowercasePathnameRedirect('Foo/Bar')).toBe('foo/bar');
@@ -2,14 +2,13 @@ import { permanentRedirect, redirect } from 'next/navigation';
import {
CustomizationPageActionType,
type RevisionPageDocument,
SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH,
SITE_REDIRECT_SOURCE_PATH_PATTERN,
} from '@gitbook/api';
import type { GitBookSiteContext } from '@/lib/context';
import { getDataOrNull } from '@/lib/data';
import { type ResolvedPagePath, resolvePageId } from '@/lib/pages';
import { resolvePageId } from '@/lib/pages';
import { withLeadingSlash } from '@/lib/paths';
import { resolveSiteSpacePagePath } from '@/lib/sites';
@@ -71,7 +70,7 @@ export async function fetchPageData(context: GitBookSiteContext, params: PagePar
* If the path can't be found, we try to resolve it from the API to handle redirects.
*/
async function resolvePage(context: GitBookSiteContext, params: PagePathParams | PageIdParams) {
const { revision } = context;
const { organizationId, site, space, revision, shareKey, linker, revisionId } = context;
if ('pageId' in params) {
return resolvePageId(revision.pages, params.pageId);
@@ -86,102 +85,72 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
return page;
}
const fallback = await resolveMissingPagePath(context, rawPathname);
if (fallback?.type === 'redirect') {
return fallback.permanent
? permanentRedirect(fallback.destination)
: redirect(fallback.destination);
}
return fallback?.page;
}
export type MissingPagePathResolution =
| {
type: 'redirect';
/** Destination as returned by `linker.toLinkForContent` (absolute path or URL). */
destination: string;
permanent: boolean;
}
| {
type: 'page';
page: ResolvedPagePath<RevisionPageDocument>;
};
/**
* Resolve a pathname that doesn't match any page of the revision, using site-level and space-level redirects.
*/
export async function resolveMissingPagePath(
context: GitBookSiteContext,
rawPathname: string
): Promise<MissingPagePathResolution | undefined> {
const { organizationId, site, space, revision, shareKey, linker, revisionId } = context;
// We don't test path that are too long as GitBook doesn't support them and will return a 404 anyway.
// API has a limit of less than 512 characters for the source path, so we use the same limit here.
if (rawPathname.length >= SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH) {
return undefined;
}
if (rawPathname.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH) {
const SITE_REDIRECT_SOURCE_PATH_REGEX = new RegExp(SITE_REDIRECT_SOURCE_PATH_PATTERN);
const redirectPathname = withLeadingSlash(rawPathname);
// If a page can't be found, we try with the API, in case we have a redirect at site level.
const redirectSources = new Set(
[
// Test the pathname relative to the root
// For example hello/world -> section/variant/hello/world
linker.toRelativePathInSite(linker.toPathInSpace(redirectPathname)),
// Test the pathname relative to the content/space
// For example hello/world -> /hello/world
redirectPathname,
]
.map(toSiteRedirectSourceCandidate)
.filter((source) => SITE_REDIRECT_SOURCE_PATH_REGEX.test(source))
);
const SITE_REDIRECT_SOURCE_PATH_REGEX = new RegExp(SITE_REDIRECT_SOURCE_PATH_PATTERN);
const redirectPathname = withLeadingSlash(rawPathname);
// If a page can't be found, we try with the API, in case we have a redirect at site level.
const redirectSources = new Set(
[
// Test the pathname relative to the root
// For example hello/world -> section/variant/hello/world
linker.toRelativePathInSite(linker.toPathInSpace(redirectPathname)),
// Test the pathname relative to the content/space
// For example hello/world -> /hello/world
redirectPathname,
]
.map(toSiteRedirectSourceCandidate)
.filter((source) => SITE_REDIRECT_SOURCE_PATH_REGEX.test(source))
);
for (const source of redirectSources) {
// We try to resolve the site redirect
const resolvedSiteRedirect =
source.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH &&
(await getDataOrNull(
context.dataFetcher.getSiteRedirectBySource({
organizationId,
siteId: site.id,
source,
siteShareKey: shareKey,
})
));
if (resolvedSiteRedirect) {
const isPublicLiveContext =
!shareKey &&
!context.changeRequest &&
!context.preview &&
context.revisionId === context.space.revision &&
!context.isLoggedInVisitor;
return {
type: 'redirect',
destination: linker.toLinkForContent(resolvedSiteRedirect.target),
permanent: Boolean(
for (const source of redirectSources) {
// We try to resolve the site redirect
const resolvedSiteRedirect =
source.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH &&
(await getDataOrNull(
context.dataFetcher.getSiteRedirectBySource({
organizationId,
siteId: site.id,
source,
siteShareKey: shareKey,
})
));
if (resolvedSiteRedirect) {
const destination = linker.toLinkForContent(resolvedSiteRedirect.target);
const isPublicLiveContext =
!shareKey &&
!context.changeRequest &&
!context.preview &&
context.revisionId === context.space.revision &&
!context.isLoggedInVisitor;
if (
resolvedSiteRedirect.redirect?.permanent &&
!resolvedSiteRedirect.redirect.draft &&
isPublicLiveContext
),
};
) {
return permanentRedirect(destination);
}
return redirect(destination);
}
}
// If page still can't be found, we try with the API, in case we have a redirect at space level.
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
// The page rendering will take care of redirecting to a normalized pathname.
const resolved = await getDataOrNull(
context.dataFetcher.getRevisionPageByPath({
spaceId: space.id,
revisionId: revisionId,
path: rawPathname,
})
);
if (resolved) {
return resolvePageId(revision.pages, resolved.id);
}
}
// If page still can't be found, we try with the API, in case we have a redirect at space level.
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
// The page rendering will take care of redirecting to a normalized pathname.
const resolved = await getDataOrNull(
context.dataFetcher.getRevisionPageByPath({
spaceId: space.id,
revisionId: revisionId,
path: rawPathname,
})
);
const page = resolved ? resolvePageId(revision.pages, resolved.id) : undefined;
return page ? { type: 'page', page } : undefined;
return undefined;
}
/**
@@ -1,23 +0,0 @@
import type { RevisionPageDocument } from '@gitbook/api';
import type { GitBookSiteContext } from '@/lib/context';
import { getSiteStructureTitle } from '@/lib/sites';
/**
* Get the <title> for a page.
*/
export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) {
const { site } = context;
const siteStructureTitle = getSiteStructureTitle(context);
const tagTitle = page.tagTitle || page.title;
return [
tagTitle,
// The first page of a section is often the same as the section title, so we don't need to show it.
tagTitle !== siteStructureTitle ? siteStructureTitle : null,
// The site title can also be the same as the page title on the site's landing page.
tagTitle !== site.title ? site.title : null,
]
.filter(Boolean)
.join(' | ');
}
@@ -201,7 +201,7 @@ function getTargetURLForSection(
return getSectionURL(context, section);
}
export function getTargetURLForSiteSpace(
function getTargetURLForSiteSpace(
context: GitBookSiteContext,
siteSpace: SiteSpace,
asEmbeddable: boolean
@@ -5,7 +5,7 @@ import { CustomizationHeaderPreset, CustomizationSearchStyle } from '@gitbook/ap
import { AdaptiveVisitorContextProvider } from '../Adaptive';
import { AIChatProvider } from '../AI';
import type { RenderAIMessageOptions } from '../AI';
import { AIChat, AskAIParagraphButton, AskAITextSelection } from '../AIChat';
import { AIChat, AskAITextSelection } from '../AIChat';
import { Announcement } from '../Announcement';
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
import { CurrentContentProvider } from '../hooks';
@@ -72,7 +72,6 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
<SpaceLayoutContextProvider
basePath={context.linker.toPathInSpace('')}
siteAdaptiveAuthLoginHref={siteAdaptiveAuthLoginHref}
isLoggedInVisitor={context.isLoggedInVisitor}
siteIndexURL={context.linker.toPathInSite('~gitbook/site-index')}
>
<AdaptiveVisitorContextProvider
@@ -138,7 +137,6 @@ export function SpaceLayout(props: SpaceLayoutProps) {
<>
<AIChat />
<AskAITextSelection />
<AskAIParagraphButton />
</>
) : null}
@@ -5,7 +5,6 @@ import React from 'react';
const SpaceLayoutContext = React.createContext({
basePath: '',
siteAdaptiveAuthLoginHref: null as string | null,
isLoggedInVisitor: false,
siteIndexURL: '',
});
@@ -16,21 +15,14 @@ export function SpaceLayoutContextProvider(
props: React.PropsWithChildren<{
basePath: string;
siteAdaptiveAuthLoginHref?: string | null;
isLoggedInVisitor?: boolean;
siteIndexURL: string;
}>
) {
const {
basePath,
siteAdaptiveAuthLoginHref = null,
isLoggedInVisitor = false,
siteIndexURL,
children,
} = props;
const { basePath, siteAdaptiveAuthLoginHref = null, siteIndexURL, children } = props;
const value = React.useMemo(
() => ({ basePath, siteAdaptiveAuthLoginHref, isLoggedInVisitor, siteIndexURL }),
[basePath, siteAdaptiveAuthLoginHref, isLoggedInVisitor, siteIndexURL]
() => ({ basePath, siteAdaptiveAuthLoginHref, siteIndexURL }),
[basePath, siteAdaptiveAuthLoginHref, siteIndexURL]
);
return <SpaceLayoutContext.Provider value={value}>{children}</SpaceLayoutContext.Provider>;
@@ -58,17 +50,6 @@ export function useSiteAdaptiveAuthLoginHref() {
return context.siteAdaptiveAuthLoginHref;
}
/**
* Return whether the visitor has a visitor auth token, with or without claims.
*/
export function useIsLoggedInVisitor() {
const context = React.useContext(SpaceLayoutContext);
if (!context) {
throw new Error('SpaceLayoutContext not found');
}
return context.isLoggedInVisitor;
}
/**
* Return the URL of the site search index (`~gitbook/site-index`).
*/
@@ -29,8 +29,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
<div ref={sentinelRef} className="h-0" aria-hidden="true" />
<div
className={tcls(
// No opacity transition on ::after: in Chrome it leaves stale pixels over the first child after the list scrolls.
'-top-4 sticky z-1 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-linear-to-b after:from-tint-base after:to-transparent',
'-top-4 sticky z-1 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-linear-to-b after:from-tint-base after:to-transparent after:transition-opacity',
isSticking ? '' : 'after:opacity-0',
'mt-1 pt-2.5 pb-0',
'bg-tint-base',
@@ -134,7 +134,6 @@ export async function TableOfContents(props: {
orientation="vertical"
contentClassName="flex flex-col p-2 gutter-stable"
active="[data-active=true]"
followActive
leading={{
fade: true,
button: {
@@ -19,7 +19,7 @@ export async function Trademark(
const { space } = context;
const language = await getSpaceLanguage(context);
const url = new URL('https://www.gitbook.com/powered-by');
const url = new URL('https://www.gitbook.com');
url.searchParams.set('utm_source', 'content');
url.searchParams.set('utm_medium', 'trademark');
url.searchParams.set('utm_campaign', space.id);
@@ -11,7 +11,7 @@ import { tcls } from '@/lib/tailwind';
* A container that encapsulates a scrollable area with usability features.
* - Faded edges when there is more content than the container can display.
* - Buttons to advance the scroll position.
* - Auto-scroll to the active item on mount and when it changes.
* - Auto-scroll to the active item when it's initially active.
*/
export type ScrollContainerProps = {
children: React.ReactNode;
@@ -42,12 +42,6 @@ export type ScrollContainerProps = {
/** The ID or ref of the active item to scroll to. */
active?: string | React.RefObject<HTMLElement | null>;
/**
* Only scroll to the active item when it is not fully visible, and keep following it
* when it changes later (requires `active` to be a selector).
*/
followActive?: boolean;
/** Scroll by one page of fully visible direct children instead of one viewport. */
scrollByVisibleItems?: boolean;
} & React.HTMLAttributes<HTMLDivElement>;
@@ -59,7 +53,6 @@ export function ScrollContainer(props: ScrollContainerProps) {
contentClassName,
orientation,
active,
followActive = false,
scrollByVisibleItems = false,
leading = { fade: true, button: true },
trailing = { fade: true, button: true },
@@ -87,52 +80,8 @@ export function ScrollContainer(props: ScrollContainerProps) {
if (!activeItem || !container.contains(activeItem)) {
return;
}
if (followActive && isElementFullyVisibleInContainer(activeItem, container)) {
return;
}
scrollToElementInContainer(activeItem, container);
}, [active, followActive]);
React.useEffect(() => {
const container = containerRef.current;
if (!followActive || !container || typeof active !== 'string') {
return;
}
let frame = 0;
let previousActiveItems = new Set(container.querySelectorAll(active));
// Active items can mount only after a collapsed group expands.
const observer = new MutationObserver(() => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const activeItems = new Set(container.querySelectorAll(active));
const newActiveItems = [...activeItems].filter(
(item) => !previousActiveItems.has(item)
);
previousActiveItems = activeItems;
// Only follow newly active items so unrelated changes (e.g. expanding another
// group) don't pull the user back to the current page.
for (const activeItem of newActiveItems) {
if (!isElementFullyVisibleInContainer(activeItem, container)) {
scrollToElementInContainer(activeItem, container, 'smooth');
return;
}
}
});
});
observer.observe(container, {
attributes: true,
attributeFilter: ['data-active'],
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
cancelAnimationFrame(frame);
};
}, [active, followActive]);
}, [active]);
const scrollFurther = () => {
const container = containerRef.current;
@@ -386,11 +335,7 @@ function scrollByViewport(
/**
* Scroll to an element in a container.
*/
export function scrollToElementInContainer(
element: Element,
container: HTMLElement,
behavior: ScrollBehavior = 'auto'
) {
function scrollToElementInContainer(element: Element, container: HTMLElement) {
const containerRect = container.getBoundingClientRect();
const rect = element.getBoundingClientRect();
@@ -405,26 +350,8 @@ export function scrollToElementInContainer(
(rect.left - containerRect.left) -
container.clientWidth / 2 +
rect.width / 2,
behavior,
// Use 'auto' to avoid additional scroll animations when scrolling to an element
// as this may be called during layout/initialization when the page is not fully loaded.
behavior: 'auto',
});
}
function isElementFullyVisibleInContainer(element: Element, container: HTMLElement) {
if (
!element.getClientRects().length ||
container.clientHeight === 0 ||
container.clientWidth === 0
) {
return true;
}
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
return (
elementRect.top >= containerRect.top &&
elementRect.bottom <= containerRect.bottom &&
elementRect.left >= containerRect.left &&
elementRect.right <= containerRect.right
);
}
+2 -6
View File
@@ -77,14 +77,10 @@ export const ar: TranslationLanguage = {
notfound_title: 'الصفحة غير موجودة',
notfound_link: 'يشير هذا الرابط إلى صفحة تمت إزالتها أو لم تعد موجودة.',
notfound: 'الصفحة التي تبحث عنها غير موجودة.',
notfound_adaptive_title: 'سجّل الدخول لعرض هذه الصفحة',
notfound_adaptive: 'قد تتطلب هذه الصفحة تسجيل الدخول. إذا اتبعت رابطًا، فتحقق من صحته.',
notfound_adaptive_title: 'الصفحة غير متاحة',
notfound_adaptive: 'قد تكون هذه الصفحة موجودة، لكن قد تحتاج إلى تسجيل الدخول للوصول إليها.',
notfound_adaptive_login: 'تسجيل الدخول',
notfound_adaptive_registration_hint: 'قد يكون التسجيل متاحًا حسب المؤسسة.',
notfound_adaptive_no_access_title: 'قد لا تملك صلاحية الوصول إلى هذه الصفحة',
notfound_adaptive_no_access:
'قد لا تكون هذه الصفحة موجودة، أو قد لا يملك حسابك صلاحية الوصول إليها. إذا اتبعت رابطًا، فتحقق من صحته.',
notfound_adaptive_login_again: 'تسجيل الدخول مجددًا',
notfound_goto_home: 'الانتقال إلى الصفحة الرئيسية',
notfound_suggestions_title: 'ربما تبحث عن',
unexpected_error_title: 'حدث خطأ',
+2 -6
View File
@@ -78,16 +78,12 @@ export const bg: TranslationLanguage = {
notfound_title: 'Страницата не е намерена',
notfound_link: 'Тази връзка сочи към страница, която е премахната или вече не съществува.',
notfound: 'Страницата, която търсите, не съществува.',
notfound_adaptive_title: 'Влезте, за да видите тази страница',
notfound_adaptive_title: 'Страницата не е налична',
notfound_adaptive:
'Тази страница може да изисква вход. Ако сте последвали връзка, проверете дали е правилна.',
'Тази страница може да съществува, но може да трябва да влезете, за да я достъпите.',
notfound_adaptive_login: 'Вход',
notfound_adaptive_registration_hint:
'Регистрацията може да е възможна в зависимост от организацията.',
notfound_adaptive_no_access_title: 'Възможно е да нямате достъп до тази страница',
notfound_adaptive_no_access:
'Тази страница може да не съществува или профилът ви може да няма достъп до нея. Ако сте последвали връзка, проверете дали е правилна.',
notfound_adaptive_login_again: 'Повторен вход',
notfound_goto_home: 'Към началната страница',
notfound_suggestions_title: 'Може би търсите',
unexpected_error_title: 'Възникна грешка',
+2 -6
View File
@@ -77,15 +77,11 @@ export const cs: TranslationLanguage = {
notfound_title: 'Stránka nenalezena',
notfound_link: 'Tento odkaz vede na stránku, která byla odstraněna nebo již neexistuje.',
notfound: 'Stránka, kterou hledáte, neexistuje.',
notfound_adaptive_title: 'Pro zobrazení této stránky se přihlaste',
notfound_adaptive_title: 'Stránka není dostupná',
notfound_adaptive:
'Tato stránka může vyžadovat přihlášení. Pokud jste přešli z odkazu, zkontrolujte, zda je správný.',
'Tato stránka může existovat, ale pro přístup se možná budete muset přihlásit.',
notfound_adaptive_login: 'Přihlásit se',
notfound_adaptive_registration_hint: 'Registrace může být dostupná v závislosti na organizaci.',
notfound_adaptive_no_access_title: 'K této stránce možná nemáte přístup',
notfound_adaptive_no_access:
'Tato stránka možná neexistuje nebo k ní váš účet nemá přístup. Pokud jste přešli z odkazu, zkontrolujte, zda je správný.',
notfound_adaptive_login_again: 'Přihlásit se znovu',
notfound_goto_home: 'Přejít na domovskou stránku',
notfound_suggestions_title: 'Možná hledáte',
unexpected_error_title: 'Došlo k chybě',
+2 -6
View File
@@ -77,15 +77,11 @@ export const da: TranslationLanguage = {
notfound_title: 'Siden blev ikke fundet',
notfound_link: 'Dette link peger på en side, der er blevet fjernet eller ikke længere findes.',
notfound: 'Siden, du leder efter, findes ikke.',
notfound_adaptive_title: 'Log ind for at se denne side',
notfound_adaptive_title: 'Siden er ikke tilgængelig',
notfound_adaptive:
'Denne side kræver muligvis, at du logger ind. Hvis du har fulgt et link, så tjek, at det er korrekt.',
'Denne side findes muligvis, men du skal muligvis logge ind for at få adgang.',
notfound_adaptive_login: 'Log ind',
notfound_adaptive_registration_hint: 'Registrering kan være mulig afhængigt af organisationen.',
notfound_adaptive_no_access_title: 'Du har muligvis ikke adgang til denne side',
notfound_adaptive_no_access:
'Denne side findes muligvis ikke, eller din konto har muligvis ikke adgang til den. Hvis du har fulgt et link, så tjek, at det er korrekt.',
notfound_adaptive_login_again: 'Log ind igen',
notfound_goto_home: 'Gå til forsiden',
notfound_suggestions_title: 'Du leder måske efter',
unexpected_error_title: 'Der opstod en fejl',
+2 -6
View File
@@ -80,16 +80,12 @@ export const de: TranslationLanguage = {
notfound_link:
'Dieser Link verweist auf eine Seite, die entfernt wurde oder nicht mehr existiert.',
notfound: 'Die gesuchte Seite existiert nicht.',
notfound_adaptive_title: 'Melden Sie sich an, um diese Seite anzuzeigen',
notfound_adaptive_title: 'Seite nicht verfügbar',
notfound_adaptive:
'Für diese Seite ist möglicherweise eine Anmeldung erforderlich. Wenn Sie einem Link gefolgt sind, prüfen Sie, ob er korrekt ist.',
'Diese Seite existiert möglicherweise, aber Sie müssen sich eventuell anmelden, um darauf zuzugreifen.',
notfound_adaptive_login: 'Anmelden',
notfound_adaptive_registration_hint:
'Die Registrierung ist je nach Organisation möglicherweise verfügbar.',
notfound_adaptive_no_access_title: 'Sie haben möglicherweise keinen Zugriff auf diese Seite',
notfound_adaptive_no_access:
'Diese Seite existiert möglicherweise nicht, oder Ihr Konto hat keinen Zugriff darauf. Wenn Sie einem Link gefolgt sind, prüfen Sie, ob er korrekt ist.',
notfound_adaptive_login_again: 'Erneut anmelden',
notfound_goto_home: 'Zur Startseite',
notfound_suggestions_title: 'Vielleicht suchen Sie nach',
unexpected_error_title: 'Ein Fehler ist aufgetreten',
+2 -6
View File
@@ -78,16 +78,12 @@ export const el: TranslationLanguage = {
notfound_title: 'Η σελίδα δεν βρέθηκε',
notfound_link: 'Αυτός ο σύνδεσμος οδηγεί σε μια σελίδα που έχει αφαιρεθεί ή δεν υπάρχει πλέον.',
notfound: 'Η σελίδα που αναζητάτε δεν υπάρχει.',
notfound_adaptive_title: 'Συνδεθείτε για να δείτε αυτή τη σελίδα',
notfound_adaptive_title: 'Η σελίδα δεν είναι διαθέσιμη',
notfound_adaptive:
'Αυτή η σελίδα ενδέχεται να απαιτεί σύνδεση. Αν ακολουθήσατε έναν σύνδεσμο, ελέγξτε ότι είναι σωστός.',
'Αυτή η σελίδα μπορεί να υπάρχει, αλλά ίσως χρειαστεί να συνδεθείτε για πρόσβαση.',
notfound_adaptive_login: 'Σύνδεση',
notfound_adaptive_registration_hint:
'Η εγγραφή ενδέχεται να είναι διαθέσιμη ανάλογα με τον οργανισμό.',
notfound_adaptive_no_access_title: 'Ίσως δεν έχετε πρόσβαση σε αυτή τη σελίδα',
notfound_adaptive_no_access:
'Αυτή η σελίδα ενδέχεται να μην υπάρχει ή ο λογαριασμός σας ενδέχεται να μην έχει πρόσβαση σε αυτήν. Αν ακολουθήσατε έναν σύνδεσμο, ελέγξτε ότι είναι σωστός.',
notfound_adaptive_login_again: 'Επανασύνδεση',
notfound_goto_home: 'Μετάβαση στην αρχική σελίδα',
notfound_suggestions_title: 'Ίσως αναζητάτε',
unexpected_error_title: 'Παρουσιάστηκε σφάλμα',

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