Compare commits

..

2 Commits

Author SHA1 Message Date
Peter White 0bd257d918 changeset 2026-10-06 16:02:05 +02:00
Peter White 8d238f76d4 Export subtree PDFs in batches of 20 with previous/next navigation 2026-10-06 16:02:05 +02:00
157 changed files with 1405 additions and 2073 deletions
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix admin toolbar magnification, tooltip placement, title truncation and logo colour.
-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
---
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.
@@ -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 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
---
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
---
Update Mermaid to 11.16.1 to match the editor, adding support for doughnut charts.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix Mermaid block diagrams falling back to a code block on published sites.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix long Mermaid labels being clipped instead of wrapping at some browser zoom levels.
-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
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Export page subtrees as PDF in batches of 20 pages, with links to the previous and next batch, so large exports no longer crash.
-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.
-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
---
Stop page group breadcrumbs from linking to a URL that redirects to the group's first page.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Site redirects whose source path has accented or non-Latin characters now redirect visitors instead of returning 404.
@@ -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
---
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.
-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.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix 404 on Markdown pages (View as Markdown) in site previews and when a theme is forced with `?theme=`.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix the admin toolbar insights button to link to the site's analytics page.
-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
-15
View File
@@ -1,15 +0,0 @@
# Slack PR status
[GitbookIO/slack-pr-status](https://github.com/GitbookIO/slack-pr-status) adds review-status reactions to PR links posted in [#engineering](https://gitbook.slack.com/archives/C01NXGWJELS). It runs from `.github/workflows/slack-pr-status.yml` in `GitbookIO/gitbook`, `GitbookIO/gitbook-x` and `GitbookIO/integrations`. Emojis and statuses are configured in that workflow; see the action's README for the options.
Owner: Peter White.
| Configuration | Value |
| --- | --- |
| Slack app | **PR Status** (`A0C2AGLAD0T`), created from `.github/slack-pr-status-app-manifest.json` |
| Bot token | Repository Actions secret `SLAPR_SLACK_BOT_TOKEN` |
| GitHub token | The workflow's `GITHUB_TOKEN`, read-only |
Posts that link PRs from more than one repository are skipped, because each workflow's token can only read its own repository. PRs from forks get no reactions, because their workflows don't receive secrets.
To disable it in one repository, run `gh workflow disable slack-pr-status.yml --repo GitbookIO/<repository>`. To stop it everywhere, revoke the PR Status app's token.
+88
View File
@@ -0,0 +1,88 @@
# Slack PR reactions
[DataDog/slapr](https://github.com/DataDog/slapr) updates reactions on existing PR-link messages in [#engineering](https://gitbook.slack.com/archives/C01NXGWJELS). It runs in GitHub Actions. It does not post messages, approve or merge PRs, or change branch protection.
## Rollout and ownership
Owner: Peter White.
The rollout targets `GitbookIO/gitbook` (GBO), `GitbookIO/gitbook-x` (GBX), and `GitbookIO/integrations`. Each repository contains `.github/workflows/slapr.yml` and this runbook.
| Configuration | Value |
| --- | --- |
| Channel | `#engineering`, `C01NXGWJELS` |
| App manifest | `.github/slapr-app-manifest.json` |
| Slack app ID | `A0C2AGLAD0T` |
| Bot user ID | `U0C2AGP16R1`, repository variable `SLAPR_BOT_USER_ID` |
| Bot token | Repository Actions secret `SLAPR_SLACK_BOT_TOKEN` |
| Action | `DataDog/slapr@1c074e1dfd433f91a679d5113f7f8b956b65d973` |
| Approval threshold | Stock default, one approval |
| Events | `pull_request_review: submitted`, `pull_request: closed` |
## Install and configure
1. In [Slack app management](https://api.slack.com/apps), create the **PR Status** app from `.github/slapr-app-manifest.json` in the GitBook workspace. Obtain workspace approval if required, then install it. The bot requests Slapr's documented `channels:history`, `reactions:read`, and `reactions:write` scopes. `#engineering` is public, so `groups:history` is unnecessary. No review map is configured, so `channels:read` is unnecessary.
2. Invite **PR Status** only to `#engineering`. Keep it dedicated to Slapr: stock Slapr removes obsolete reactions belonging to this bot on a matched message. It leaves other users' reactions alone.
3. Record the app ID and bot user ID above. The bot profile's member ID or Slack `auth.test` response's `user_id` is the required bot user ID, not the app ID or bot ID.
4. Store the bot OAuth token as `SLAPR_SLACK_BOT_TOKEN` in each of the three repositories' Actions secrets. Use the GitHub secret UI or `gh secret set SLAPR_SLACK_BOT_TOKEN --repo GitbookIO/<repository>` and its hidden prompt. Never paste the token into a command argument, chat, workflow, or committed file. Alternatively, an organization secret must use selected-repository visibility limited to these three repositories.
5. Set Actions variable `SLAPR_BOT_USER_ID` in each repository to that bot's user ID. The token and user ID must belong to the same installation. Use `gh variable set SLAPR_BOT_USER_ID --repo GitbookIO/<repository> --body '<user-id>'` or the GitHub UI.
6. Merge the workflows through normal review, then run the live checks below. Repository maintainers handle reviews and merges; Slapr has only read access to GitHub contents and pull requests.
GBX runs this workflow on `blacksmith-2vcpu-ubuntu-2404`. GBO and integrations use `ubuntu-latest`, matching their existing workflows.
The workflow hardcodes the engineering channel and does not check out PR code. It uses the stock event triggers and leaves the approval threshold unset. There are no listeners, schedules, review maps, custom discovery, or additional services.
## Reactions
Slapr's status selection stays unchanged. Missing custom emoji names are mapped through the action's supported inputs to existing equivalents. Workspace lookup on 2026-09-17 confirmed `pr-approved` and `merged`; the other mappings are standard Slack emojis.
| Stock status | Reaction |
| --- | --- |
| Review started | `:eyes:` |
| Partially approved | `:next_track_button:` |
| Approved | `:pr-approved:` |
| Changes requested | `:construction:` |
| Merged | `:merged:` |
| Closed without merging | `:no_entry_sign:` |
| Comment-only review | `:speech_balloon:` |
The partially-approved reaction is configured but cannot occur with the default threshold of one approval. It becomes relevant only if that threshold is raised.
On approval, expect review-started and approved reactions. On merge, stock Slapr removes review-started and adds merged; it can retain approved alongside merged. Approval and merging have distinct reactions. Other stock review-state behavior is accepted.
## Live acceptance test
For each repository after credentials are configured and the workflow is on the default branch:
1. Open a fresh same-repository test PR through normal review. Post its full `https://github.com/GitbookIO/<repository>/pull/<number>` link as a new top-level message in `#engineering` before the review event.
2. Have another contributor approve it. Open the **Slack PR reactions** Actions run and verify that the matched message has the bot's `pr-approved` reaction.
3. Have an authorized maintainer merge it. Verify the closed-event run succeeds and the same message has the bot's `merged` reaction. No new Slack status message should appear.
4. Record the PR URL, Slack message permalink, approval/merge workflow run URLs, date, and result below. A green workflow alone does not prove the message was found or reacted to.
| Repository | PR and Slack message | Approval run/result | Merge run/result |
| --- | --- | --- | --- |
| GitbookIO/gitbook | Pending | Not tested | Not tested |
| GitbookIO/gitbook-x | Pending | Not tested | Not tested |
| GitbookIO/integrations | Pending | Not tested | Not tested |
Only the first matching message in Slapr's recent-history response is updated. Older messages, thread replies, duplicate posts, fork PRs, and links posted after the event are outside this rollout's acceptance criteria. Slapr does not paginate history or reconcile missed events.
## Troubleshoot and disable
- No workflow run: confirm the workflow is merged, Actions permits the pinned action, and the event was a submitted review or PR closure. These are the only configured triggers.
- `No message found requesting review`: confirm the full PR link was posted as a recent top-level message in `#engineering` before the event. This is a successful no-op in stock Slapr.
- `not_in_channel` or `channel_not_found`: check the app installation, channel ID, and bot membership in `#engineering`.
- `missing_scope`: compare the installed bot scopes with the manifest and reinstall after changes.
- `invalid_auth` or `account_inactive`: replace the repository secrets with the current bot token. If the bot identity changed, update `SLAPR_BOT_USER_ID` too.
- `invalid_name`: check the emoji mappings still exist. If a custom emoji was removed, map that input to another existing emoji, keeping approval and merged distinct.
- Old reactions remain or removal fails: confirm `SLAPR_BOT_USER_ID` is the member ID for the token's bot.
- GitHub permission errors: keep `contents: read` and `pull-requests: read`; do not introduce a PAT, write permissions, or `pull_request_target` to cover forks. Fork and Dependabot secret restrictions are accepted limitations.
- Rate limits or transient API failures: inspect the Actions log and retry the failed run after the limit resets. There is no scheduled reconciliation.
Disable one repository with `gh workflow disable slapr.yml --repo GitbookIO/<repository>`, or disable **Slack PR reactions** in its Actions UI. Repeat for all three to stop the integration. Revoke the Slack app token to stop its access immediately, remove its repository secrets, and remove the bot from `#engineering` when retiring it. Existing reactions remain.
## Pin review
On 2026-09-17, the pinned action definition, Dockerfile, entry point, dependency declarations, GitHub reads, Slack history lookup and reaction writes were inspected. Without `review-map`, the action targets only the supplied channel. It reads PR/review state and adds/removes reactions; it does not post Slack messages or write to GitHub.
The action SHA pins Slapr's source. Its upstream Dockerfile still uses the mutable `python:3.14` image and installs dependencies, including `pyyaml>=5.0`, at build time. This rollout keeps stock Slapr unchanged. Review upstream source and dependency changes before updating the SHA in all three repositories.
-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:
-33
View File
@@ -1,33 +0,0 @@
name: Slack PR status
on:
pull_request_review:
types: [submitted, dismissed]
pull_request:
types: [closed, reopened]
permissions:
contents: read
pull-requests: read
jobs:
slack-pr-status:
# Secrets aren't available to workflows triggered from forks.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: GitbookIO/slack-pr-status@main
with:
slack-token: ${{ secrets.SLAPR_SLACK_BOT_TOKEN }}
config: |
channels: [C01NXGWJELS]
approvalsRequired: 1
multiPr: aggregate
emojis:
commented: false
partiallyApproved: next_track_button
approved: pr-approved
changesRequested: construction
merged: merged
closed: no_entry_sign
partiallyReviewed: hourglass_flowing_sand
+30
View File
@@ -0,0 +1,30 @@
name: Slack PR reactions
on:
pull_request_review:
types: [submitted]
pull_request:
types: [closed]
permissions:
contents: read
pull-requests: read
jobs:
run_slapr:
runs-on: ubuntu-latest
steps:
- name: Update PR reactions in engineering
uses: DataDog/slapr@1c074e1dfd433f91a679d5113f7f8b956b65d973
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
slack-api-token: ${{ secrets.SLAPR_SLACK_BOT_TOKEN }}
slack-channel-id: C01NXGWJELS
bot-user-id: ${{ vars.SLAPR_BOT_USER_ID }}
emoji-review-started: eyes
emoji-partially-approved: next_track_button
emoji-approved: pr-approved
emoji-changes-requested: construction
emoji-merged: merged
emoji-closed: no_entry_sign
emoji-commented: speech_balloon
+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.
+40 -17
View File
@@ -155,7 +155,7 @@
"mdast-util-gfm": "^3.1.0",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.17",
"mermaid": "11.16.1",
"mermaid": "^11.14.0",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
@@ -164,7 +164,7 @@
"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",
@@ -343,7 +343,6 @@
"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",
"mermaid@11.16.1": "patches/mermaid@11.16.1.patch",
},
"overrides": {
"@codemirror/state": "6.4.1",
@@ -356,7 +355,7 @@
},
"catalog": {
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.206.0",
"@gitbook/api": "0.204.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -572,7 +571,15 @@
"@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="],
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.1.1", "", { "dependencies": { "@chevrotain/gast": "11.1.1", "@chevrotain/types": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-fRHyv6/f542qQqiRGalrfJl/evD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw=="],
"@chevrotain/gast": ["@chevrotain/gast@11.1.1", "", { "dependencies": { "@chevrotain/types": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+dfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg=="],
"@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.1.1", "", {}, "sha512-ctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+82C2uylg/TEwFRgwLmbhlln4qkmDyteg=="],
"@chevrotain/types": ["@chevrotain/types@11.1.1", "", {}, "sha512-wb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw=="],
"@chevrotain/utils": ["@chevrotain/utils@11.1.1", "", {}, "sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ=="],
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
@@ -720,7 +727,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.204.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-SJCe0Ipyt+V5HZX4EZpFO8AHTOWIMwj2ljQTbcqgU7rVOAyCNDzILp8mRk9stbboSi1pv09+rDnZK1ZguPvFTw=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -878,7 +885,7 @@
"@mermaid-js/mermaid-zenuml": ["@mermaid-js/mermaid-zenuml@0.2.2", "", { "dependencies": { "@zenuml/core": "^3.35.2" }, "peerDependencies": { "mermaid": "^10 || ^11" } }, "sha512-sUjwk4NWUpy9uaHypYSIGJDks10ZaZo5CHH9lx9xcmyqv9w7yvd4vecUmlUQxmlHStYO+aqSkYKX5/gFjDfypw=="],
"@mermaid-js/parser": ["@mermaid-js/parser@1.2.1", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw=="],
"@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.17.5", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-QakrKIGniGuRVfWBdMsDea/dx1PNE739QJ7gCM41s9q+qaCYTHCdsIBXQVVXry3mfWAiaM9kT22Hyz53Uw8mfg=="],
@@ -1744,6 +1751,10 @@
"chardet": ["chardet@2.1.0", "", {}, "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA=="],
"chevrotain": ["chevrotain@11.1.1", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.1.1", "@chevrotain/gast": "11.1.1", "@chevrotain/regexp-to-ast": "11.1.1", "@chevrotain/types": "11.1.1", "@chevrotain/utils": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ=="],
"chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="],
"chokidar": ["chokidar@4.0.0", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-mxIojEAQcuEvT/lyXq+jf/3cO/KoA6z4CeNDGGevTybECPOMFCnQy3OPahluUkbqgPNGw5Bi78UC7Po6Lhy+NA=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
@@ -1836,7 +1847,7 @@
"cva": ["cva@1.0.0-beta.2", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "typescript": ">= 4.5.5 < 6" }, "optionalPeers": ["typescript"] }, "sha512-dqcOFe247I5pKxfuzqfq3seLL5iMYsTgo40Uw7+pKZAntPgFtR7Tmy59P5IVIq/XgB0NQWoIvYDt9TwHkuK8Cg=="],
"cytoscape": ["cytoscape@3.34.4", "", {}, "sha512-msY99tMY8iWTg+sIPGfSqDcPNOt4xcj+v/K/7qUFaCUxxlqnMkUYLBjRUzn9W5CU/R2hrmtJa5+UtovrEM2ctg=="],
"cytoscape": ["cytoscape@3.33.1", "", {}, "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ=="],
"cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="],
@@ -1912,7 +1923,7 @@
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="],
"dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -1962,7 +1973,7 @@
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"dompurify": ["dompurify@3.4.17", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w=="],
"dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
@@ -2020,8 +2031,6 @@
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-toolkit": ["es-toolkit@1.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="],
"es5-ext": ["es5-ext@0.10.64", "", { "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg=="],
"es6-iterator": ["es6-iterator@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g=="],
@@ -2392,7 +2401,7 @@
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
"katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
"katex": ["katex@0.16.25", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q=="],
"keyv": ["keyv@5.3.2", "", { "dependencies": { "@keyv/serialize": "^1.0.3" } }, "sha512-Lji2XRxqqa5Wg+CHLVfFKBImfJZ4pCSccu9eVWK6w4c2SDFLd8JAn1zqTuSFnsxb7ope6rMsnIHfp+eBbRBRZQ=="],
@@ -2404,6 +2413,8 @@
"known-css-properties": ["known-css-properties@0.35.0", "", {}, "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A=="],
"langium": ["langium@4.2.1", "", { "dependencies": { "chevrotain": "~11.1.1", "chevrotain-allstar": "~0.3.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ=="],
"layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="],
"leven": ["leven@4.1.0", "", {}, "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew=="],
@@ -2528,7 +2539,7 @@
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"mermaid": ["mermaid@11.16.1", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g=="],
"mermaid": ["mermaid@11.14.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
@@ -2666,7 +2677,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=="],
@@ -2922,6 +2933,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=="],
@@ -3208,6 +3221,18 @@
"vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="],
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
"vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="],
"vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="],
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
"vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"vue": ["vue@3.5.21", "", { "dependencies": { "@vue/compiler-dom": "3.5.21", "@vue/compiler-sfc": "3.5.21", "@vue/runtime-dom": "3.5.21", "@vue/server-renderer": "3.5.21", "@vue/shared": "3.5.21" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA=="],
"vue-component-type-helpers": ["vue-component-type-helpers@3.1.1", "", {}, "sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw=="],
@@ -3670,8 +3695,6 @@
"@vue/compiler-sfc/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"@zenuml/core/dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
"@zenuml/core/marked": ["marked@4.3.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A=="],
"@zenuml/core/tailwind-merge": ["tailwind-merge@3.4.1", "", {}, "sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q=="],
+2 -3
View File
@@ -48,7 +48,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.204.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -67,7 +67,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",
"mermaid@11.16.1": "patches/mermaid@11.16.1.patch"
"next@16.3.8": "patches/next@16.3.8.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,
-24
View File
@@ -1125,30 +1125,6 @@ const testCases: TestsCase[] = [
).toBeVisible();
},
},
{
name: 'Markdown page with theme cookie',
url: async () => {
const data = await getSiteAPIToken(
'https://gitbook.gitbook.io/test-gitbook-open/'
);
const searchParams = new URLSearchParams();
searchParams.set('token', data.apiToken);
searchParams.set('theme', 'dark');
return `url/${getGitBookPreviewURL(`${data.site}/blocks?${searchParams.toString()}`)}`;
},
screenshot: false,
run: async (page) => {
// The app opens previews with `?theme=`, which the middleware persists in a cookie.
const markdownURL = new URL(page.url());
markdownURL.pathname = `${markdownURL.pathname.replace(/\/$/, '')}.md`;
markdownURL.search = '';
const response = await page.request.get(markdownURL.toString());
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('text/markdown');
},
},
{
name: 'Redirect to app for authentication when missing token',
url: async () => {
-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,
+2 -2
View File
@@ -46,7 +46,7 @@
"mdast-util-gfm": "^3.1.0",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.17",
"mermaid": "11.16.1",
"mermaid": "^11.14.0",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
@@ -55,7 +55,7 @@
"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",
@@ -1,6 +1,5 @@
import { build } from 'bun';
import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, rm } from 'node:fs/promises';
import { mkdir, rename, rm } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -13,20 +12,14 @@ const temporaryDir = join(outputDir, '.build');
const require = createRequire(import.meta.url);
const mermaidPackage = require('mermaid/package.json') as { version: string };
const zenumlPackage = require('@mermaid-js/mermaid-zenuml/package.json') as { version: string };
const mermaidPatch = await readFile(
join(scriptDir, `../../../patches/mermaid@${mermaidPackage.version}.patch`)
).catch(() => null);
const mermaidPatchSuffix = mermaidPatch
? `-patch.${createHash('sha256').update(mermaidPatch).digest('hex').slice(0, 8)}`
: '';
// The runtime URL is served as immutable, it has to change whenever the bundled versions or patch change.
// The runtime URL is served as immutable, it has to change whenever the bundled versions change.
if (
MERMAID_RUNTIME_PATH !==
`mermaid/mermaid@${mermaidPackage.version}${mermaidPatchSuffix}-zenuml@${zenumlPackage.version}.mjs`
`mermaid/mermaid@${mermaidPackage.version}-zenuml@${zenumlPackage.version}.mjs`
) {
throw new Error(
'Update MERMAID_RUNTIME_PATH for the installed mermaid and @mermaid-js/mermaid-zenuml versions and the mermaid patch'
'Update MERMAID_RUNTIME_PATH for the installed mermaid and @mermaid-js/mermaid-zenuml versions'
);
}
+4 -5
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 });
}
@@ -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();
};
@@ -15,6 +15,7 @@ import {
ToolbarBody,
ToolbarButton,
ToolbarButtonGroup,
type ToolbarButtonProps,
ToolbarSubtitle,
ToolbarTitle,
} from './Toolbar';
@@ -136,21 +137,14 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle
prefix={`#${changeRequest.number}`}
prefixLabel={`Change request #${changeRequest.number}`}
suffix={changeRequest.subject || 'Untitled'}
prefix={`Change #${changeRequest.number}:`}
suffix={`${changeRequest.subject || 'Untitled'}`}
/>
<ToolbarSubtitle
subtitle={
<>
<ToolbarDate value={changeRequest.updatedAt} />{' '}
<motion.span
layout="position"
title={author}
className="min-w-0 truncate"
>
by {author}
</motion.span>
<motion.span layout="position">by {author}</motion.span>
</>
}
/>
@@ -323,7 +317,7 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
<ToolbarButton
title="Open insights"
href={getToolbarHref({
href: `${site.urls.app}/analytics`,
href: `${site.urls.app}/insights`,
siteId: site.id,
buttonId: 'insights',
})}
@@ -345,8 +339,12 @@ function ToolbarActions(props: { children: React.ReactNode }) {
);
}
function EditPageButton(props: { href: string; siteId: string }) {
const { href, siteId } = props;
function EditPageButton(props: {
href: string;
siteId: string;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { href, motionValues, siteId } = props;
const pagePath = useCurrentPagePath();
return (
@@ -358,6 +356,7 @@ function EditPageButton(props: { href: string; siteId: string }) {
buttonId: 'edit',
})}
icon="pen-to-square"
motionValues={motionValues}
/>
);
}
@@ -1,6 +1,5 @@
.svgLogo {
/* The toolbar is always dark, so the logo is always light. */
--logo-fill: rgb(255 255 255 / 0.92);
--logo-fill: var(--color-neutral-100);
--seg-A-color: #2782c4;
--seg-B-color: #43b7f2;
--seg-C-color: #8be2ff;
@@ -11,14 +11,8 @@ export const AnimatedLogo: React.FC<AnimatedLogoProps> = (props) => {
const { shouldAnimate = true } = props;
return (
// Light even without the CSS module, which loads with the lazy toolbar chunk and can be
// missing (e.g. after a hot reload): an SVG path without a fill renders black.
<svg
className={tcls(
'text-white/90',
styles.svgLogo,
shouldAnimate ? undefined : styles.static
)}
className={tcls(styles.svgLogo, shouldAnimate ? undefined : styles.static)}
width="28"
height="28"
viewBox="0 0 28 28"
@@ -28,7 +22,6 @@ export const AnimatedLogo: React.FC<AnimatedLogoProps> = (props) => {
<path
d={dPath}
transform="translate(14,14) scale(0.4)"
fill="currentColor"
strokeWidth={3}
pathLength={1}
strokeLinecap="round"
@@ -6,9 +6,8 @@ import type { SVGProps } from 'react';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from '../primitives';
import { useToolbarSlot } from './Toolbar';
import type { ToolbarButtonProps } from './Toolbar';
import type { MinimalChangedPage, MinimalChangedPages } from './types';
import { TOOLTIP_OFFSET } from './useMagnificationEffect';
import { tcls } from '@/lib/tailwind';
const STATUS_ICON_STYLES: Record<
@@ -43,10 +42,12 @@ const STATUS_ICON_CHIP_CLASS = 'flex size-5 shrink-0 items-center justify-center
/**
* A button that opens a popover with quick links to the _changed_ pages.
*/
export function ChangedPagesButton(props: { changedPages: MinimalChangedPages | null }) {
const { changedPages } = props;
export function ChangedPagesButton(props: {
changedPages: MinimalChangedPages | null;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { changedPages, motionValues } = props;
const reduceMotion = useReducedMotion();
const slot = useToolbarSlot();
if (!changedPages?.pages.length) {
return null;
@@ -58,19 +59,30 @@ export function ChangedPagesButton(props: { changedPages: MinimalChangedPages |
}`;
const trigger = (
<motion.button
<button
type="button"
aria-label="View changed pages"
style={
reduceMotion || !slot
? undefined
: { scale: slot.scale, x: slot.x, transformOrigin: 'bottom center' }
}
className="relative size-8 cursor-pointer border-0 bg-transparent p-0"
>
<span
<motion.span
aria-hidden="true"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
'relative flex size-8 items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'text-tint-7 hover:text-tint-1',
'dark:text-tint-12',
@@ -79,18 +91,14 @@ export function ChangedPagesButton(props: { changedPages: MinimalChangedPages |
)}
>
<DiffIcon className="size-4 shrink-0" />
</span>
</motion.button>
</motion.span>
</button>
);
return (
<DropdownMenu
button={trigger}
buttonTooltip="View changed pages"
// Like the other toolbar buttons: against the unscaled slot, with room to magnify.
buttonTooltipProps={
slot ? { anchor: slot.anchorRef, sideOffset: TOOLTIP_OFFSET } : undefined
}
side="top"
align="end"
sideOffset={12}
@@ -5,7 +5,7 @@ import { useOnClickOutside } from 'usehooks-ts';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { ToolbarButton, useToolbarSlot } from './Toolbar';
import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
import styles from './Toolbar.module.css';
import { useToolbarControls } from './ToolbarControlsContext';
import { tcls } from '@/lib/tailwind';
@@ -26,11 +26,15 @@ const ARC_PARAMS = {
offsetAnchorY: 40,
} as const;
interface HideToolbarButtonProps {
motionValues?: ToolbarButtonProps['motionValues'];
}
/**
* Hide menu trigger. Expands a macOS Dock-like submenu with 3 labeled actions.
*/
export function HideToolbarButton() {
const slot = useToolbarSlot();
export function HideToolbarButton(props: HideToolbarButtonProps) {
const { motionValues } = props;
const [open, setOpen] = React.useState(false);
const [closing, setClosing] = React.useState(false);
const controls = useToolbarControls();
@@ -110,8 +114,11 @@ export function HideToolbarButton() {
: null,
].filter(Boolean) as ArcMenuItem[];
// The arc follows the button's push, but not its scale.
const sharedMotionStyle = slot ? { x: slot.x } : undefined;
const sharedMotionStyle = motionValues
? {
x: motionValues.x,
}
: undefined;
return (
<ToolbarButton
@@ -129,6 +136,7 @@ export function HideToolbarButton() {
setOpen(true);
}
}}
motionValues={motionValues}
icon="gear"
>
{/* Expanding arc menu */}
@@ -1,13 +1,16 @@
'use client';
import React from 'react';
import { ToolbarButton } from './Toolbar';
import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
/**
* Button to refresh the page if the content has been updated.
*/
export function RefreshContentButton(props: { refreshForUpdates: () => Promise<void> }) {
const { refreshForUpdates } = props;
export function RefreshContentButton(props: {
refreshForUpdates: () => Promise<void>;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { refreshForUpdates, motionValues } = props;
const [loading, setLoading] = React.useState(false);
@@ -31,6 +34,7 @@ export function RefreshContentButton(props: { refreshForUpdates: () => Promise<v
refresh();
}}
disabled={loading}
motionValues={motionValues}
icon="rotate"
iconClassName={loading ? 'animate-spin' : undefined}
/>
@@ -1,6 +1,12 @@
'use client';
import { type MotionValue, motion, useReducedMotion, useSpring } from 'motion/react';
import React from 'react';
import {
AnimatePresence,
type MotionValue,
motion,
useReducedMotion,
useSpring,
} from 'motion/react';
import React, { isValidElement } from 'react';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
@@ -9,7 +15,7 @@ import { AnimatedLogo } from './AnimatedLogo';
import { useToolbarControls } from './ToolbarControlsContext';
import { ToolbarVisibilityHint } from './ToolbarVisibilityHint';
import { getCopyVariants, toolbarEasings } from './transitions';
import { TOOLTIP_OFFSET, useMagnificationEffect } from './useMagnificationEffect';
import { useMagnificationEffect } from './useMagnificationEffect';
import { tcls } from '@/lib/tailwind';
const DURATION_LOGO_APPEARANCE = 2000;
@@ -102,93 +108,63 @@ export function Toolbar(props: ToolbarProps) {
return (
<motion.div
data-testid="admin-toolbar"
// Centered with `inset-x-0 mx-auto`: at `left-1/2` it could only grow to half the viewport,
// which pushed the logo out of the pill.
className="fixed inset-x-0 bottom-5 z-40 mx-auto w-fit max-w-xl px-4"
className="fixed bottom-5 left-1/2 z-40 w-auto max-w-xl -translate-x-1/2 transform px-4"
>
<ToolbarVisibilityHint show={showHint} />
<motion.div
onClick={() => {
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
}}
// Only minifying and expanding animate: content width changes (e.g. the date
// format) would otherwise run a bouncy scale animation that stretches the content.
layout
layoutDependency={minified}
onLayoutAnimationComplete={() => {
if (!minified) {
setShowHint(true);
}
}}
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer' : 'pr-2 pl-3.5',
'relative',
'flex',
'items-center',
'justify-center',
'min-h-11',
'min-w-12',
'h-12',
'py-2',
'origin-center',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'bg-[var(--toolbar-bg)]'
)}
style={PILL_STYLE}
>
{/* Logo — double-click to minimize */}
<AnimatePresence mode="wait">
<motion.div
layout
layoutDependency={minified}
onDoubleClick={(e) => {
if (minified) return;
e.stopPropagation();
window.getSelection()?.removeAllRanges();
onMinifiedChange(true);
onClick={() => {
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
}}
layout
onLayoutAnimationComplete={() => {
if (!minified) {
setShowHint(true);
}
}}
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer' : 'pr-2 pl-3.5',
'relative',
'flex',
'items-center',
'justify-center',
'min-h-11',
'min-w-12',
'h-12',
'py-2',
'origin-center',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'bg-[var(--toolbar-bg)]'
)}
style={PILL_STYLE}
>
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
</motion.div>
{/* A layout child, so it's counter-scaled while the pill expands. */}
{!minified ? (
{/* Logo — double-click to minimize */}
<motion.div
layout
layoutDependency={minified}
className="flex min-w-0 items-center"
onDoubleClick={(e) => {
if (minified) return;
e.stopPropagation();
window.getSelection()?.removeAllRanges();
onMinifiedChange(true);
}}
>
{children}
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
</motion.div>
) : null}
</motion.div>
{!minified ? children : null}
</motion.div>
</AnimatePresence>
</motion.div>
);
}
export function ToolbarBody(props: { children: React.ReactNode }) {
// Shrinks so a long title truncates, but keeps room for the longest date format.
return <div className="flex min-w-40 flex-col items-start px-3">{props.children}</div>;
}
const MAGNIFICATION_SPRING = { stiffness: 400, damping: 30 };
/** A button's magnification, and the unscaled box to place its tooltip against. */
type ToolbarSlot = {
scale: MotionValue<number>;
x: MotionValue<number>;
anchorRef: React.RefObject<HTMLDivElement | null>;
};
const ToolbarSlotContext = React.createContext<ToolbarSlot | null>(null);
/** The magnification of the toolbar button group slot this is rendered in, if any. */
export function useToolbarSlot() {
return React.useContext(ToolbarSlotContext);
return <div className="flex flex-col items-start px-3">{props.children}</div>;
}
export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
@@ -196,8 +172,8 @@ export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
const containerRef = React.useRef<HTMLDivElement>(null);
const buttonChildren = React.Children.toArray(children).filter((child) => !!child);
const { motionValues } = useMagnificationEffect({
slotCount: buttonChildren.length,
const { buttonMotionValues } = useMagnificationEffect({
childrenCount: buttonChildren.length,
containerRef,
});
@@ -207,51 +183,28 @@ export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
variants={toolbarEasings.parent}
initial="hidden"
animate="show"
// `relative`: the magnification measures slots by their `offsetLeft` in here.
className="relative flex items-center gap-1 overflow-visible pl-4 pr-2"
className="flex items-center gap-1 overflow-visible pl-4 pr-2"
>
{buttonChildren.map((child, index) => {
const values = motionValues[index];
return values ? (
<ToolbarSlot
key={React.isValidElement(child) && child.key ? child.key : index}
scale={values.scale}
x={values.x}
>
{child}
</ToolbarSlot>
) : null;
const childEl = child as React.ReactElement;
const childKey = childEl.key ?? `toolbar-button-${index}`;
return (
<ToolbarButtonWrapper
key={childKey}
child={childEl}
rawMotionValues={buttonMotionValues[index]}
/>
);
})}
</motion.div>
);
}
/**
* One button of a group: staggers in, and smooths its magnification for the button inside.
* Collapses when the button renders nothing, so it leaves no gap.
*/
function ToolbarSlot(props: {
scale: MotionValue<number>;
x: MotionValue<number>;
children: React.ReactNode;
}) {
const anchorRef = React.useRef<HTMLDivElement>(null);
const scale = useSpring(props.scale, MAGNIFICATION_SPRING);
const x = useSpring(props.x, MAGNIFICATION_SPRING);
const slot = React.useMemo(() => ({ scale, x, anchorRef }), [scale, x]);
return (
<motion.div
ref={anchorRef}
variants={toolbarEasings.staggeringChild}
className="relative empty:hidden"
>
<ToolbarSlotContext.Provider value={slot}>{props.children}</ToolbarSlotContext.Provider>
</motion.div>
);
}
export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorElement>, 'title'> {
motionValues?: {
scale: MotionValue<number>;
x: MotionValue<number>;
};
icon: IconName;
iconClassName?: string;
title?: React.ReactNode;
@@ -259,10 +212,19 @@ export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorEleme
}
export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps>((props, ref) => {
const { title, disabled, className, style, href, onClick, icon, iconClassName, children } =
props;
const {
title,
disabled,
motionValues,
className,
style,
href,
onClick,
icon,
iconClassName,
children,
} = props;
const reduceMotion = useReducedMotion();
const slot = useToolbarSlot();
const anchor = (
<motion.a
@@ -271,11 +233,23 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
target="_blank"
rel="noopener noreferrer"
style={
reduceMotion || !slot
? style
: { scale: slot.scale, x: slot.x, transformOrigin: 'bottom center', ...style }
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
...style,
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
className,
'relative flex size-8 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'text-tint-7 hover:text-tint-1',
@@ -288,68 +262,75 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
<Icon
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls('size-3.5 shrink-0', iconClassName)}
className={tcls('size-3.5 shrink-0 group-hover:scale-110', iconClassName)}
/>
</motion.a>
);
return (
<div className="relative" ref={ref}>
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
{children ? children : null}
{/* Anchored to the unscaled slot with room for full magnification, so the gap doesn't
depend on how far the spring has grown when the tooltip opens. */}
{title ? (
<Tooltip
label={title}
anchor={slot?.anchorRef}
sideOffset={slot ? TOOLTIP_OFFSET : 4}
>
{anchor}
</Tooltip>
) : (
anchor
)}
</div>
{title ? <Tooltip label={title}>{anchor}</Tooltip> : anchor}
</motion.div>
);
});
ToolbarButton.displayName = 'ToolbarButton';
export function ToolbarTitle(props: {
prefix?: string;
/** What a short prefix stands for, e.g. "Change request #54" for "#54". */
prefixLabel?: string;
suffix: string;
function ToolbarButtonWrapper(props: {
child: React.ReactElement;
rawMotionValues?: { scale: MotionValue<number>; x: MotionValue<number> };
}) {
const { prefix, prefixLabel, suffix } = props;
// One tooltip for the whole title: the short prefix spelled out, and the full, often
// truncated, subject.
const label = prefixLabel ? (
<span className="flex flex-col">
<span className="opacity-70 text-xxs">{prefixLabel}</span>
<span className="font-medium">{suffix}</span>
</span>
) : prefix ? (
`${prefix}${suffix}`
) : (
suffix
);
const { child, rawMotionValues } = props;
// Convert the raw motion values to smooth spring easings
const springScale = useSpring(rawMotionValues?.scale.get() ?? 1, {
stiffness: 400,
damping: 30,
});
const springX = useSpring(rawMotionValues?.x.get() ?? 0, { stiffness: 400, damping: 30 });
// Sync springs with raw motion values
React.useEffect(() => {
if (!rawMotionValues) return;
const unsubScale = rawMotionValues.scale.on('change', (v) => springScale.set(v));
const unsubX = rawMotionValues.x.on('change', (v) => springX.set(v));
return () => {
unsubScale();
unsubX();
};
}, [rawMotionValues, springScale, springX]);
const motionValues = {
scale: springScale,
x: springX,
};
if (!isValidElement<{ motionValues: typeof motionValues }>(child)) {
return null;
}
return React.cloneElement(child, {
motionValues,
});
}
export function ToolbarTitle(props: { prefix?: string; suffix: string }) {
return (
<Tooltip label={label}>
<div className="flex min-w-0 max-w-full items-center gap-1 text-xs">
{prefix ? <ToolbarTitlePrefix title={prefix} /> : null}
<ToolbarTitleSuffix title={suffix} />
</div>
</Tooltip>
<div className="flex items-center gap-1 text-xs">
{props.prefix ? <ToolbarTitlePrefix title={props.prefix} /> : null}
<ToolbarTitleSuffix title={props.suffix} />
</div>
);
}
function ToolbarTitlePrefix(props: { title: string }) {
// Kept whole: prefixes are short, and the suffix is the one to truncate.
return (
<motion.span
{...getCopyVariants(0)}
className="shrink-0 whitespace-nowrap font-medium text-neutral-1 dark:text-neutral-12"
className="truncate font-medium text-neutral-1 dark:text-neutral-12"
>
{props.title}
</motion.span>
@@ -360,7 +341,7 @@ function ToolbarTitleSuffix(props: { title: string }) {
return (
<motion.span
{...getCopyVariants(1)}
className="min-w-0 max-w-[28ch] truncate font-bold text-neutral-1 dark:text-neutral-12"
className="max-w-[20ch] truncate font-bold text-neutral-1 dark:text-neutral-12"
>
{props.title}
</motion.span>
@@ -371,7 +352,7 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
return (
<motion.span
{...getCopyVariants(1)}
className="text-neutral-1/80 dark:text-neutral-12/80 flex min-w-0 max-w-full items-center gap-1 whitespace-nowrap text-xxs"
className="text-neutral-1/80 dark:text-neutral-12/80 inline-flex items-center gap-1 text-xxs"
>
{props.subtitle}
</motion.span>
@@ -35,8 +35,7 @@ export function ToolbarDate(props: { value: string }) {
return (
<motion.div
// Never wraps: the longer formats push the author's name to truncate instead.
className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap"
className="inline-flex items-center gap-1"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
@@ -76,7 +75,7 @@ export function ToolbarDate(props: { value: string }) {
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
}}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="relative inline-flex cursor-pointer select-none overflow-hidden whitespace-nowrap font-semibold transition-colors hover:text-white" // `text-white` is used insead of dark-mode adapting values because the date is always in a dark-tinted toolbar
className="relative inline-flex cursor-pointer select-none overflow-hidden font-semibold transition-colors hover:text-white" // `text-white` is used insead of dark-mode adapting values because the date is always in a dark-tinted toolbar
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
@@ -1,4 +1,4 @@
import { type MotionValue, motionValue, useReducedMotion } from 'motion/react';
import { type MotionValue, motionValue } from 'motion/react';
import React from 'react';
interface MagnificationConfig {
@@ -16,29 +16,40 @@ interface MagnificationConfig {
padding?: number;
}
export const BUTTON_SIZE = 32;
export const MAX_SCALE = 1.25;
/** Tooltip gap above a slot, with room for the button fully magnified (it grows from the bottom). */
export const TOOLTIP_OFFSET = 4 + BUTTON_SIZE * (MAX_SCALE - 1);
const defaultConfig: Required<MagnificationConfig> = {
buttonSize: BUTTON_SIZE, // Standard button size for 32px (size-8) buttons
maxScale: MAX_SCALE, // 25% scale increase - noticeable but not overwhelming
buttonSize: 32, // Standard button size for 32px (size-8) buttons
maxScale: 1.25, // 25% scale increase - noticeable but not overwhelming
influenceRadius: 80, // ~2.5 button widths of influence
spacingMultiplier: 1.3, // Creates proportional spacing to scale increase
scaleExponent: 2.5, // Exponential curve for dramatic close-range scaling
padding: 10, // Small buffer zone around container edges
};
type SlotMotionValues = { scale: MotionValue<number>; x: MotionValue<number> };
const resetMotionValues = (motionValues: SlotMotionValues[]) => {
const resetMotionValues = (
motionValues: { scale: MotionValue<number>; x: MotionValue<number> }[]
) => {
motionValues.forEach(({ scale, x }) => {
scale.set(1);
x.set(0);
});
};
const captureButtonPositions = (buttons: HTMLElement[]) => {
// Reset transforms to get original positions
buttons.forEach((button) => {
button.style.transform = '';
});
return buttons.map((button) => {
const rect = button.getBoundingClientRect();
return {
left: rect.left,
width: rect.width,
top: rect.top,
height: rect.height,
};
});
};
const calculateScale = (
mouseX: number,
buttonCenterX: number,
@@ -51,7 +62,8 @@ const calculateScale = (
if (distance > config.influenceRadius) return 1;
// Calculate distance from container edge (X-axis only)
const distanceFromEdge = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
const distanceFromEdgeX = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
const distanceFromEdge = distanceFromEdgeX;
// Define the "heart" zone - 8px from center (16x16px total area)
const heartZoneRadius = 8;
@@ -82,20 +94,24 @@ const calculateScale = (
const calculateSpacing = (
buttonIndex: number,
scales: number[],
centers: (number | null)[],
buttonEffects: { scale: number; translateX: number }[],
positions: { left: number; width: number; top: number; height: number }[],
config: Required<MagnificationConfig>
) => {
const buttonCenterX = centers[buttonIndex];
if (buttonCenterX == null) return 0;
const currentPos = positions[buttonIndex];
if (!currentPos) return 0;
const buttonCenterX = currentPos.left + currentPos.width / 2;
let leftPush = 0;
let rightPush = 0;
scales.forEach((otherScale, otherIndex) => {
const otherCenterX = centers[otherIndex];
if (otherIndex === buttonIndex || otherCenterX == null) return;
buttonEffects.forEach((otherEffect, otherIndex) => {
if (otherIndex === buttonIndex) return;
const otherPos = positions[otherIndex];
if (!otherPos) return;
const otherCenterX = otherPos.left + otherPos.width / 2;
const distanceBetween = Math.abs(buttonCenterX - otherCenterX);
// Only apply influence if close enough
@@ -104,7 +120,7 @@ const calculateSpacing = (
0,
1 - distanceBetween / (config.influenceRadius * 1.5)
);
const extraSpace = (otherScale - 1) * config.buttonSize;
const extraSpace = (otherEffect.scale - 1) * config.buttonSize;
const pushAmount = (extraSpace / 2) * config.spacingMultiplier * influenceStrength;
if (otherCenterX < buttonCenterX) {
@@ -118,88 +134,104 @@ const calculateSpacing = (
return rightPush - leftPush;
};
/**
* Dock-like magnification of the slots (direct children) of `containerRef`. Returns raw motion
* values per slot index, meant to be smoothed with springs by the slots.
*/
export function useMagnificationEffect(props: {
slotCount: number;
childrenCount: number;
containerRef: React.RefObject<HTMLElement | null>;
config?: MagnificationConfig;
}) {
const { slotCount, containerRef, config } = props;
const reduceMotion = useReducedMotion();
const { childrenCount, containerRef, config } = props;
const originalPositionsRef = React.useRef<
{ left: number; width: number; top: number; height: number }[]
>([]);
const finalConfig = React.useMemo(() => ({ ...defaultConfig, ...config }), [config]);
// Stable across renders, grown as slots are added, so existing springs keep their source.
const motionValuesRef = React.useRef<SlotMotionValues[]>([]);
while (motionValuesRef.current.length < slotCount) {
motionValuesRef.current.push({ scale: motionValue(1), x: motionValue(0) });
}
const motionValues = motionValuesRef.current;
// Create basic motion values that will be consumed by springs in the components
const buttonMotionValues = React.useMemo(() => {
return Array.from({ length: childrenCount }, () => ({
scale: motionValue(1),
x: motionValue(0),
}));
}, [childrenCount]);
React.useEffect(() => {
const container = containerRef.current;
if (!container || reduceMotion) {
resetMotionValues(motionValues);
if (!container) return;
const buttons = Array.from(container.querySelectorAll('.toolbar-button')) as HTMLElement[];
if (buttons.length !== childrenCount) {
console.error(
`Button count (${buttons.length}) does not match children count (${childrenCount})`
);
return;
}
let frame = 0;
let pointer = { x: 0, y: 0 };
const handleMouseMove = (event: MouseEvent) => {
const buttons = Array.from(
container.querySelectorAll('.toolbar-button')
) as HTMLElement[];
// Measured every frame from layout positions (`offsetLeft`), which the slots' transforms
// don't affect, so nothing goes stale when the toolbar resizes, re-centers or animates in.
const update = () => {
frame = 0;
const rect = container.getBoundingClientRect();
const inRange =
pointer.x >= rect.left - finalConfig.padding &&
pointer.x <= rect.right + finalConfig.padding &&
pointer.y >= rect.top - finalConfig.padding &&
pointer.y <= rect.bottom + finalConfig.padding;
if (!inRange) {
resetMotionValues(motionValues);
// Capture positions if needed
if (originalPositionsRef.current.length !== buttons.length) {
originalPositionsRef.current = captureButtonPositions(buttons);
}
const containerRect = container.getBoundingClientRect();
const { clientX: mouseX, clientY: mouseY } = event;
// Check if mouse is in range
const isInRange =
mouseX >= containerRect.left - finalConfig.padding &&
mouseX <= containerRect.right + finalConfig.padding &&
mouseY >= containerRect.top - finalConfig.padding &&
mouseY <= containerRect.bottom + finalConfig.padding;
if (!isInRange) {
resetMotionValues(buttonMotionValues);
return;
}
const slots = Array.from(container.children) as HTMLElement[];
// Empty or hidden slots take no space and don't magnify.
const centers = slots.map((slot) =>
slot.offsetWidth > 0 ? rect.left + slot.offsetLeft + slot.offsetWidth / 2 : null
);
const scales = centers.map((center) =>
center == null ? 1 : calculateScale(pointer.x, center, rect, finalConfig)
);
motionValues.forEach((values, index) => {
values.scale.set(scales[index] ?? 1);
values.x.set(calculateSpacing(index, scales, centers, finalConfig));
// Calculate scales for all buttons
const buttonEffects = buttons.map((_, index) => {
const pos = originalPositionsRef.current[index];
if (!pos) return { scale: 1, translateX: 0 };
const buttonCenterX = pos.left + pos.width / 2;
const scale = calculateScale(mouseX, buttonCenterX, containerRect, finalConfig);
return { scale, translateX: 0 };
});
// Calculate spacing for all buttons
buttonEffects.forEach((effect, index) => {
effect.translateX = calculateSpacing(
index,
buttonEffects,
originalPositionsRef.current,
finalConfig
);
});
// Update motion values
buttonEffects.forEach((effect, index) => {
const motionValue = buttonMotionValues[index];
if (motionValue) {
motionValue.scale.set(effect.scale);
motionValue.x.set(effect.translateX);
}
});
};
const onPointerMove = (event: PointerEvent) => {
if (event.pointerType !== 'mouse') {
return;
}
pointer = { x: event.clientX, y: event.clientY };
if (!frame) {
frame = requestAnimationFrame(update);
}
};
const onPointerLeave = () => {
cancelAnimationFrame(frame);
frame = 0;
resetMotionValues(motionValues);
};
const handleMouseLeave = () => resetMotionValues(buttonMotionValues);
container.addEventListener('mousemove', handleMouseMove);
container.addEventListener('mouseleave', handleMouseLeave);
container.addEventListener('pointermove', onPointerMove);
container.addEventListener('pointerleave', onPointerLeave);
return () => {
cancelAnimationFrame(frame);
container.removeEventListener('pointermove', onPointerMove);
container.removeEventListener('pointerleave', onPointerLeave);
container.removeEventListener('mousemove', handleMouseMove);
container.removeEventListener('mouseleave', handleMouseLeave);
};
}, [containerRef, finalConfig, motionValues, reduceMotion]);
}, [finalConfig, containerRef, buttonMotionValues, childrenCount]);
return { motionValues };
return { buttonMotionValues };
}
@@ -1 +1 @@
export const MERMAID_RUNTIME_PATH = 'mermaid/mermaid@11.16.1-patch.78ecb855-zenuml@0.2.2.mjs';
export const MERMAID_RUNTIME_PATH = 'mermaid/mermaid@11.14.0-zenuml@0.2.2.mjs';
@@ -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,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',
@@ -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,7 +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';
@@ -78,7 +77,6 @@ export async function EmbeddableRootLayout({
}}
>
<NavigationLoader />
<AskAIParagraphButton />
<VisibilityProvider className="fixed inset-0 flex flex-col">
{children}
{context.customization.trademark.enabled ? (
@@ -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',
@@ -3,6 +3,13 @@ import { describe, expect, it } from 'bun:test';
import { RevisionPageType } from '@gitbook/api';
import { createPDFLinker } from './linker';
import { selectPages } from './selectPages';
import {
PDF_DEFAULT_LIMIT,
PDF_SUBTREE_DEFAULT_LIMIT,
getPDFSearchParams,
getPDFURLSearchParams,
} from './urls';
import { createLinker, linkerWithAbsoluteURLs } from '@/lib/links';
import { getLinkerForSiteSpace } from '@/lib/sites';
@@ -15,6 +22,10 @@ function createDocumentPage(id: string, path: string) {
} as any;
}
function createDocumentPageWithChildren(id: string, children: any[]) {
return { ...createDocumentPage(id, id), pages: children };
}
function createGroupPage(id: string, path: string) {
return {
id,
@@ -208,3 +219,183 @@ describe('createPDFLinker', () => {
).toBe('https://open-2v.gitbook.com/~space/HJ1ltuWFvsArFWtevnRn~gitbook/pdf/respond');
});
});
describe('createPDFLinker with batches', () => {
const baseLinker = createLinker({
host: 'open-2v.gitbook.com',
siteBasePath: '/~space/HJ1ltuWFvsArFWtevnRn',
spaceBasePath: '/~space/HJ1ltuWFvsArFWtevnRn',
});
const getBatchURL = (page: { id: string }) =>
page.id === 'other-batch'
? 'https://open-2v.gitbook.com/~gitbook/pdf?offset=20'
: undefined;
it('prefers the published URL for documents exported in another batch', () => {
const linker = createPDFLinker(
baseLinker,
[{ page: createDocumentPage('included', '') }],
createPublishedLinker(),
{ getBatchURL }
);
expect(
linker.toPathForPage({
pages: [
createDocumentPage('included', ''),
createDocumentPage('other-batch', 'respond'),
],
page: createDocumentPage('other-batch', 'respond'),
})
).toBe('https://docs.vectra.ai/deployment/respond');
});
it('links to the batch containing a page when there is no published URL', () => {
const linker = createPDFLinker(
baseLinker,
[{ page: createDocumentPage('included', '') }],
undefined,
{ getBatchURL }
);
expect(
linker.toPathForPage({
pages: [createDocumentPage('other-batch', 'respond')],
page: createDocumentPage('other-batch', 'respond'),
anchor: 'faq',
})
).toBe('https://open-2v.gitbook.com/~gitbook/pdf?offset=20#page-other-batch-faq');
});
it('links group pages to the batch containing them', () => {
const linker = createPDFLinker(
baseLinker,
[{ page: createDocumentPage('included', '') }],
createPublishedLinker(),
{ getBatchURL }
);
expect(
linker.toPathForPage({
pages: [createGroupPage('other-batch', 'group')],
page: createGroupPage('other-batch', 'group'),
})
).toBe('https://open-2v.gitbook.com/~gitbook/pdf?offset=20#page-other-batch');
});
});
describe('getPDFSearchParams', () => {
it('defaults to a small batch for subtree exports', () => {
expect(getPDFSearchParams(new URLSearchParams('page=abc&only=yes'))).toEqual({
page: 'abc',
only: true,
limit: PDF_SUBTREE_DEFAULT_LIMIT,
offset: 0,
});
expect(PDF_SUBTREE_DEFAULT_LIMIT).toBe(20);
});
it('keeps the larger default for whole-space and single-page exports', () => {
expect(getPDFSearchParams(new URLSearchParams('')).limit).toBe(PDF_DEFAULT_LIMIT);
expect(getPDFSearchParams(new URLSearchParams('page=abc')).limit).toBe(PDF_DEFAULT_LIMIT);
expect(PDF_DEFAULT_LIMIT).toBe(100);
});
it('parses an explicit limit and offset', () => {
const params = getPDFSearchParams(
new URLSearchParams('page=abc&only=yes&limit=5&offset=10')
);
expect(params.limit).toBe(5);
expect(params.offset).toBe(10);
});
it('ignores invalid limit and offset values', () => {
const params = getPDFSearchParams(
new URLSearchParams('page=abc&only=yes&limit=nope&offset=-3')
);
expect(params.limit).toBe(PDF_SUBTREE_DEFAULT_LIMIT);
expect(params.offset).toBe(0);
expect(getPDFSearchParams(new URLSearchParams('limit=0&offset=1.5'))).toMatchObject({
limit: PDF_DEFAULT_LIMIT,
offset: 0,
});
});
it('round-trips through getPDFURLSearchParams', () => {
const params = { page: 'abc', only: true, limit: 20, offset: 40, back: 'false' };
expect(getPDFSearchParams(getPDFURLSearchParams(params))).toEqual(params);
});
it('omits offset and limit when not set', () => {
expect(getPDFURLSearchParams({ page: 'abc', only: true }).toString()).toBe(
'page=abc&only=yes'
);
});
});
describe('selectPages', () => {
const children = Array.from({ length: 45 }, (_, index) =>
createDocumentPage(`child-${index}`, `child-${index}`)
);
const rootPages = [createDocumentPageWithChildren('root', children)];
const ids = (pages: { page: { id: string } }[] | undefined) =>
(pages ?? []).map(({ page }) => page.id);
const childIds = (from: number, to: number) => children.slice(from, to).map((page) => page.id);
it('renders the first batch of a subtree by default', () => {
const selection = selectPages(
rootPages,
getPDFSearchParams(new URLSearchParams('page=root&only=yes'))
);
expect(selection?.total).toBe(46);
expect(selection?.offset).toBe(0);
expect(ids(selection?.pages)).toEqual(['root', ...childIds(0, 19)]);
expect(selection?.allPages).toHaveLength(46);
});
it('slices the batch from offset to offset + limit', () => {
const selection = selectPages(rootPages, {
page: 'root',
only: true,
limit: 20,
offset: 20,
});
expect(selection?.offset).toBe(20);
expect(ids(selection?.pages)).toEqual(childIds(19, 39));
});
it('returns a shorter last batch', () => {
const selection = selectPages(rootPages, {
page: 'root',
only: true,
limit: 20,
offset: 40,
});
expect(ids(selection?.pages)).toEqual(childIds(39, 45));
});
it('falls back to the last batch when offset is past the end', () => {
const selection = selectPages(rootPages, {
page: 'root',
only: true,
limit: 20,
offset: 100,
});
expect(selection?.offset).toBe(26);
expect(selection?.pages).toHaveLength(20);
});
it('only selects the page itself without only', () => {
const selection = selectPages(rootPages, { page: 'root', limit: 100, offset: 0 });
expect(ids(selection?.pages)).toEqual(['root']);
expect(selection?.total).toBe(1);
});
it('returns null for an unknown page', () => {
expect(selectPages(rootPages, { page: 'missing', limit: 100, offset: 0 })).toBeNull();
});
});
+30 -73
View File
@@ -3,7 +3,6 @@ import { notFound } from 'next/navigation';
import * as React from 'react';
import {
type Revision,
type RevisionPageDocument,
type RevisionPageGroup,
type SiteCustomizationSettings,
@@ -16,7 +15,8 @@ import { ImagesLoadingStatus } from './ImagesLoadingStatus';
import { createPDFLinker, getPagePDFContainerId } from './linker';
import { PageControlButtons } from './PageControlButtons';
import { PDFPrintControls } from './PDFPrintControls';
import { type PDFSearchParams, getPDFSearchParams } from './urls';
import { selectPages } from './selectPages';
import { getPDFSearchParams, getPDFURLSearchParams } from './urls';
import { DocumentView } from '@/components/DocumentView';
import { Trademark } from '@/components/TableOfContents/Trademark';
import type { PolymorphicComponentProp } from '@/components/utils/types';
@@ -27,13 +27,10 @@ import type { GitBookSiteContext, GitBookSpaceContext } from '@/lib/context';
import { getPageDocument } from '@/lib/data';
import { type GitBookLinker, createLinker, linkerWithAbsoluteURLs } from '@/lib/links';
import './pdf.css';
import { resolvePageId } from '@/lib/pages';
import { getLinkerForSiteSpace } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { defaultCustomization } from '@/lib/utils';
const DEFAULT_LIMIT = 100;
/**
* Generate the metadata for the PDF page.
*/
@@ -62,13 +59,37 @@ export async function PDFPage(props: {
const language = await getSpaceLanguage(baseContext);
// Compute the pages to render
const { pages, total } = selectPages(baseContext.revision.pages, pdfParams);
const selection = selectPages(baseContext.revision.pages, pdfParams);
if (!selection) {
notFound();
}
const { pages, allPages, offset, total } = selection;
const batchParams = { ...pdfParams, offset };
const pageIds = pages.map(
({ page }) => [page.id, getPagePDFContainerId(page)] as [string, string]
);
// Build a linker that create anchor links for the pages rendered in the PDF page.
const linker = createPDFLinker(baseContext.linker, pages, getPublishedLinker(baseContext));
const pageIndexes = new Map(allPages.map(({ page }, index) => [page.id, index]));
const linker = createPDFLinker(baseContext.linker, pages, getPublishedLinker(baseContext), {
getBatchURL: (page) => {
const index = pageIndexes.get(page.id);
if (index === undefined) {
return undefined;
}
// Align on the current batch so these links match the previous/next controls.
const batchOffset = Math.max(
0,
offset + Math.floor((index - offset) / pdfParams.limit) * pdfParams.limit
);
return baseContext.linker.toAbsoluteURL(
baseContext.linker.toPathInSpace(
`~gitbook/pdf?${getPDFURLSearchParams({ ...pdfParams, offset: batchOffset }).toString()}`
)
);
},
});
const context: GitBookSpaceContext = {
...baseContext,
@@ -113,7 +134,7 @@ export async function PDFPage(props: {
</div>
<PageControlButtons
params={pdfParams}
params={batchParams}
pageIds={pageIds}
total={total}
trademark={
@@ -126,7 +147,7 @@ export async function PDFPage(props: {
}
/>
{pdfParams.only ? null : (
{pdfParams.only || offset > 0 ? null : (
<PDFSpaceIntro space={context.space} customization={customization} />
)}
{pages.map(({ page }) =>
@@ -273,67 +294,3 @@ function PrintPage(
</div>
);
}
type FlatPageEntry = { page: RevisionPageDocument | RevisionPageGroup; depth: number };
/**
* Compute the ordered flat set of pages to render.
*/
function selectPages(
rootPages: Revision['pages'],
params: PDFSearchParams
): { pages: FlatPageEntry[]; total: number } {
const flattenPage = (
page: RevisionPageDocument | RevisionPageGroup,
depth: number
): FlatPageEntry[] => {
return [
{ page, depth },
...page.pages.flatMap((child) => {
if (child.type !== 'document') {
return [];
}
if (child.hidden) {
return [];
}
return flattenPage(child, depth + 1);
}),
];
};
const limitTo = (entries: FlatPageEntry[]) => {
return {
// Apply a soft-limit, the limit can be controlled by the URL to allow testing
pages: entries.slice(0, params.limit ?? DEFAULT_LIMIT),
total: entries.length,
};
};
if (params.page) {
const found = resolvePageId(rootPages, params.page);
if (!found) {
notFound();
}
if (!params.only) {
return limitTo([{ page: found.page, depth: 0 }]);
}
return limitTo(flattenPage(found.page, 0));
}
const allPages = rootPages.flatMap((page) => {
if (page.type !== 'document' && page.type !== 'group') {
return [];
}
if (page.hidden) {
return [];
}
return flattenPage(page, 0);
});
return limitTo(allPages);
}
@@ -10,8 +10,6 @@ import { Button } from '@/components/primitives';
import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
const limitExtend = 50;
/**
* Dynamic controls to show active page and to let the user select between modes.
*/
@@ -19,7 +17,7 @@ export function PageControlButtons(props: {
params: PDFSearchParams;
/** Array of the [pageId, divId] */
pageIds: [string, string][];
/** Total number of pages targetted by the generation */
/** Total number of pages targetted by the generation, across all batches */
total: number;
/** Trademark to display */
trademark?: React.ReactNode;
@@ -38,6 +36,10 @@ export function PageControlButtons(props: {
const activeIndex = (activeDivId ? divIds.indexOf(activeDivId) : 0) + 1;
const activePageId = pageIds[activeIndex - 1]?.[0];
const batchEnd = params.offset + pageIds.length;
const previousCount = Math.min(params.limit, params.offset);
const nextCount = Math.min(params.limit, total - batchEnd);
return (
<>
<div
@@ -58,6 +60,8 @@ export function PageControlButtons(props: {
...params,
page: activePageId,
only: true,
limit: undefined,
offset: undefined,
}).toString()}`}
variant="secondary"
label={tString(language, 'pdf_mode_only_page')}
@@ -68,6 +72,8 @@ export function PageControlButtons(props: {
...params,
page: undefined,
only: false,
limit: undefined,
offset: undefined,
}).toString()}`}
variant="secondary"
label={tString(language, 'pdf_mode_all')}
@@ -89,7 +95,7 @@ export function PageControlButtons(props: {
'z-50'
)}
>
{total !== pageIds.length ? (
{previousCount > 0 || nextCount > 0 ? (
<div
role="banner"
className={tcls(
@@ -112,19 +118,32 @@ export function PageControlButtons(props: {
className={tcls('size-6', 'mr-3', 'mt-1')}
/>{' '}
<div>
<div>{t(language, 'pdf_limit_reached', total, pageIds.length)}</div>
<div>
<a
href={`?${getPDFURLSearchParams({
...params,
page: undefined,
only: false,
limit: params.limit + limitExtend,
}).toString()}`}
className={tcls('underline')}
>
{t(language, 'pdf_limit_reached_continue', limitExtend)}
</a>
{t(language, 'pdf_batch_range', params.offset + 1, batchEnd, total)}
</div>
<div className={tcls('flex', 'flex-row', 'flex-wrap', 'gap-x-3')}>
{previousCount > 0 ? (
<a
href={`?${getPDFURLSearchParams({
...params,
offset: params.offset - previousCount,
}).toString()}`}
className={tcls('underline')}
>
{t(language, 'pdf_batch_previous', previousCount)}
</a>
) : null}
{nextCount > 0 ? (
<a
href={`?${getPDFURLSearchParams({
...params,
offset: batchEnd,
}).toString()}`}
className={tcls('underline')}
>
{t(language, 'pdf_batch_next', nextCount)}
</a>
) : null}
</div>
</div>
</div>
@@ -146,7 +165,7 @@ export function PageControlButtons(props: {
'border'
)}
>
{t(language, 'pdf_page_of', activeIndex, pageIds.length)}
{t(language, 'pdf_page_of', params.offset + activeIndex, total)}
</div>
</div>
</>
+21 -18
View File
@@ -20,36 +20,39 @@ export function getPagePDFContainerId(
/**
* Create a custom linker for PDF exports.
*
* This linker generates in-document anchor links for pages that are included
* in the current PDF export, using `getPagePDFContainerId` to build the
* target element ID. For pages that are not part of the exported PDF, it
* falls back to URLs pointing to the published site if `publishedLinker` is
* provided, preserving navigability for external content. Otherwise, it uses
* absolute URLs from the base linker.
*
* @param baseLinker - The base GitBook linker used to resolve standard paths and URLs.
* @param pages - The list of pages that are included in the current PDF export.
* @param publishedLinker - Optional linker for the published space for external page links.
* @returns A `GitBookLinker` configured to generate PDF-friendly links.
* Pages rendered in the current batch get in-document anchors. Other pages link to the
* published site when possible; pages exported in another batch otherwise link to that batch.
*/
export function createPDFLinker(
baseLinker: GitBookLinker,
pages: { page: Revision['pages'][number] }[],
publishedLinker?: GitBookLinker
publishedLinker?: GitBookLinker,
options: {
/** URL of the export batch rendering a page, for pages outside the current batch. */
getBatchURL?: (page: RevisionPageDocument | RevisionPageGroup) => string | undefined;
} = {}
): GitBookLinker {
const pageIds = new Set(pages.map((p) => p.page.id));
return {
...baseLinker,
toPathForPage(input) {
if (pages.some((p) => p.page.id === input.page.id)) {
if (pageIds.has(input.page.id)) {
return `#${getPagePDFContainerId(input.page, input.anchor)}`;
}
if (input.page.type === RevisionPageType.Group) {
return '#';
// A published page outlives the export URL, so prefer it for documents.
if (input.page.type !== RevisionPageType.Group && publishedLinker) {
return publishedLinker.toPathForPage(input);
}
// For pages that are not embedded in this PDF export, keep links on the published site.
if (publishedLinker) {
return publishedLinker.toPathForPage(input);
const batchURL = options.getBatchURL?.(input.page);
if (batchURL) {
return `${batchURL}#${getPagePDFContainerId(input.page, input.anchor)}`;
}
if (input.page.type === RevisionPageType.Group) {
return '#';
}
return baseLinker.toAbsoluteURL(baseLinker.toPathForPage(input));
@@ -0,0 +1,92 @@
import type { Revision, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
import type { PDFSearchParams } from './urls';
import { resolvePageId } from '@/lib/pages';
export type FlatPageEntry = { page: RevisionPageDocument | RevisionPageGroup; depth: number };
export interface PDFPageSelection {
/** Pages rendered in the current batch */
pages: FlatPageEntry[];
/** Every page targeted by the export, across all batches */
allPages: FlatPageEntry[];
/** Index in `allPages` of the first page of the batch */
offset: number;
/** Total number of pages targeted by the export */
total: number;
}
/**
* Compute the ordered flat set of pages targeted by the export, and the batch to render.
* Returns `null` if the requested page doesn't exist.
*/
export function selectPages(
rootPages: Revision['pages'],
params: Pick<PDFSearchParams, 'page' | 'only' | 'limit' | 'offset'>
): PDFPageSelection | null {
const allPages = selectAllPages(rootPages, params);
if (!allPages) {
return null;
}
const total = allPages.length;
// Fall back to the last batch rather than rendering an empty one.
const offset = params.offset < total ? params.offset : Math.max(0, total - params.limit);
return {
pages: allPages.slice(offset, offset + params.limit),
allPages,
offset,
total,
};
}
function selectAllPages(
rootPages: Revision['pages'],
params: Pick<PDFSearchParams, 'page' | 'only'>
): FlatPageEntry[] | null {
if (params.page) {
const found = resolvePageId(rootPages, params.page);
if (!found) {
return null;
}
if (!params.only) {
return [{ page: found.page, depth: 0 }];
}
return flattenPage(found.page, 0);
}
return rootPages.flatMap((page) => {
if (page.type !== 'document' && page.type !== 'group') {
return [];
}
if (page.hidden) {
return [];
}
return flattenPage(page, 0);
});
}
function flattenPage(
page: RevisionPageDocument | RevisionPageGroup,
depth: number
): FlatPageEntry[] {
return [
{ page, depth },
...page.pages.flatMap((child) => {
if (child.type !== 'document') {
return [];
}
if (child.hidden) {
return [];
}
return flattenPage(child, depth + 1);
}),
];
}
+56 -12
View File
@@ -1,33 +1,61 @@
const DEFAULT_LIMIT = 100;
/** Default number of pages rendered per request when exporting the whole space. */
export const PDF_DEFAULT_LIMIT = 100;
/**
* Default number of pages rendered per request when exporting a page and its subtree.
* Subtrees are often OpenAPI references, which are much heavier to render than prose
* and get the request killed for CPU/memory at higher limits.
*/
export const PDF_SUBTREE_DEFAULT_LIMIT = 20;
export interface PDFSearchParams {
/** Page to export. If none is passed, all pages are exported. */
page?: string;
/** If true, only the `page` is exported, and not its descendant */
/** If true, the `page` is exported with its descendants */
only?: boolean;
/** Limit the number of pages */
/** Maximum number of pages to render in this batch */
limit: number;
/** Index of the first page to render in this batch */
offset: number;
/** URL to redirect back to */
back?: string;
}
/**
* Params accepted when building a PDF export URL. `limit` and `offset` fall back to the defaults.
*/
export type PDFURLParams = Omit<PDFSearchParams, 'limit' | 'offset'> & {
limit?: number;
offset?: number;
};
/**
* Default batch size for a given PDF export.
*/
export function getDefaultPDFLimit(params: Pick<PDFSearchParams, 'page' | 'only'>): number {
return params.page && params.only ? PDF_SUBTREE_DEFAULT_LIMIT : PDF_DEFAULT_LIMIT;
}
/**
* Get the PDF export params from the URL serch params.
*/
export function getPDFSearchParams(searchParams: URLSearchParams): PDFSearchParams {
const page = searchParams.has('page') ? (searchParams.get('page') ?? '') : undefined;
const only = searchParams.has('only') ? true : undefined;
const params: PDFSearchParams = {
limit: DEFAULT_LIMIT,
limit:
parseInteger(searchParams.get('limit'), { min: 1 }) ??
getDefaultPDFLimit({ page, only }),
offset: parseInteger(searchParams.get('offset'), { min: 0 }) ?? 0,
};
if (searchParams.has('page')) {
params.page = searchParams.get('page') ?? '';
if (page !== undefined) {
params.page = page;
}
if (searchParams.has('only')) {
if (only) {
params.only = true;
}
if (searchParams.has('limit')) {
params.limit = Number(searchParams.get('limit'));
}
if (searchParams.has('back')) {
params.back = searchParams.get('back') ?? '';
}
@@ -39,7 +67,7 @@ export function getPDFSearchParams(searchParams: URLSearchParams): PDFSearchPara
* Get the URL search params to use for a PDF export.
*/
export function getPDFURLSearchParams(
params: PDFSearchParams,
params: PDFURLParams,
searchParams = new URLSearchParams({})
): URLSearchParams {
if (params?.page) {
@@ -53,9 +81,16 @@ export function getPDFURLSearchParams(
searchParams.delete('only');
}
// Persist limit and back
// Persist limit, offset and back
if (params?.limit) {
searchParams.set('limit', String(params.limit));
} else {
searchParams.delete('limit');
}
if (params?.offset) {
searchParams.set('offset', String(params.offset));
} else {
searchParams.delete('offset');
}
if (params?.back) {
searchParams.set('back', String(params.back));
@@ -63,3 +98,12 @@ export function getPDFURLSearchParams(
return searchParams;
}
function parseInteger(value: string | null, options: { min: number }): number | undefined {
if (value === null || value.trim() === '') {
return undefined;
}
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= options.min ? parsed : undefined;
}
@@ -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
@@ -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,
})),
@@ -222,14 +214,9 @@ export async function PageHeader(props: {
))}
{ancestors.map((breadcrumb, index) => {
const parentPages = ancestors[index - 1]?.pages ?? revision.pages;
// A group's own URL redirects to its first document, so link straight to it.
const target =
breadcrumb.type === 'group'
? (resolveFirstDocument([breadcrumb], [])?.page ?? breadcrumb)
: breadcrumb;
const href = linker.toPathForPage({
pages: revision.pages,
page: target,
page: breadcrumb,
});
return (
<li key={breadcrumb.id} className="inline">
@@ -461,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;
}
/**
@@ -488,16 +471,15 @@ function getPageSiblings(
if (page.hidden && page.id !== activeId) {
continue;
}
// A group's own URL redirects to its first document, so link straight to it. A group whose
// subtree has no document resolves to no page, so don't offer it (its path would 404).
const target = page.type === 'group' ? resolveFirstDocument([page], [])?.page : page;
if (!target) {
// A group whose subtree has no document resolves to no page — the sidebar renders it as a
// non-clickable label, so don't offer it as a navigable sibling (its path would 404).
if (page.type === 'group' && page.id !== activeId && !resolveFirstDocument([page], [])) {
continue;
}
result.push({
id: page.id,
title: page.linkTitle || page.title,
href: context.linker.toPathForPage({ pages: context.revision.pages, page: target }),
href: context.linker.toPathForPage({ pages: context.revision.pages, page }),
emoji: page.emoji,
icon: page.icon,
isActive: page.id === activeId,
@@ -551,7 +533,6 @@ function getPageActionsURLs({
`~gitbook/pdf?${getPDFURLSearchParams({
page: page.id,
only: true,
limit: 100,
}).toString()}`
)
: undefined,
@@ -284,11 +284,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();
}
@@ -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.
@@ -97,11 +97,11 @@ describe('fetchPageData', () => {
describe('resolveMissingPagePath', () => {
function createRedirectContext(options: {
siteRedirect?: { source?: string; target: string; permanent?: boolean };
siteRedirect?: { target: string; permanent?: boolean };
spaceRedirectPageId?: string;
}) {
const getSiteRedirectBySource = mock(async ({ source }: { source: string }) =>
options.siteRedirect && source === (options.siteRedirect.source ?? '/old')
options.siteRedirect && source === '/old'
? {
data: {
target: options.siteRedirect.target,
@@ -143,24 +143,6 @@ describe('resolveMissingPagePath', () => {
});
});
it('resolves a site redirect whose source has non-ASCII characters', async () => {
const context = createRedirectContext({
siteRedirect: {
source: '/de/kategorien-hinzuf%C3%BCgen',
target: 'https://docs.example.com/new',
},
});
const pathname = normalizeURL(
new URL('https://docs.example.com/de/kategorien-hinzufügen')
).pathname.slice(1);
expect(await resolveMissingPagePath(context, pathname)).toEqual({
type: 'redirect',
destination: '/new',
permanent: false,
});
});
it('resolves a space redirect to a page', async () => {
const context = createRedirectContext({ spaceRedirectPageId: page.id });
@@ -186,8 +186,7 @@ export async function resolveMissingPagePath(
/**
* Transform a pathname into a candidate source for site redirect matching.
* The pathname is already percent-encoded, so each segment is decoded before being encoded
* again: redirect sources store special characters percent-encoded exactly once.
* We also encode each segment to handle special characters in redirects.
*/
function toSiteRedirectSourceCandidate(pathname: string): string {
const normalized = withLeadingSlash(pathname);
@@ -195,19 +194,11 @@ function toSiteRedirectSourceCandidate(pathname: string): string {
normalized
.slice(1)
.split('/')
.map((segment) => encodeURIComponent(safeDecodeURIComponent(segment)))
.map((segment) => encodeURIComponent(segment))
.join('/')
);
}
function safeDecodeURIComponent(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
/**
* Get the page path from the params.
*/
@@ -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`).
*/
@@ -9,7 +9,7 @@ import { Icon, type IconName } from '@gitbook/icons';
import { Link, type LinkInsightsProps } from '.';
import { ToggleChevron } from './ToggleChevron';
import { Tooltip, type TooltipProps } from './Tooltip';
import { Tooltip } from './Tooltip';
import { type ClassValue, tcls } from '@/lib/tailwind';
export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit<
@@ -41,8 +41,6 @@ export function DropdownMenu(props: {
button: React.ReactElement<Record<string, unknown>>;
/** Tooltip label for the button */
buttonTooltip?: React.ReactNode;
/** Placement of the button's tooltip. */
buttonTooltipProps?: Pick<TooltipProps, 'side' | 'sideOffset' | 'anchor'>;
/** Content of the dropdown */
children: React.ReactNode;
/** Custom styles */
@@ -74,7 +72,6 @@ export function DropdownMenu(props: {
const {
button,
buttonTooltip,
buttonTooltipProps,
children,
className,
openOnHover = false,
@@ -101,7 +98,7 @@ export function DropdownMenu(props: {
<DropdownMenuContext.Provider value={{ open, setOpen }}>
<Menu.Root modal={false} open={open} onOpenChange={setOpen}>
{buttonTooltip ? (
<Tooltip label={buttonTooltip} disableHoverablePopup {...buttonTooltipProps}>
<Tooltip label={buttonTooltip} disableHoverablePopup>
{trigger}
</Tooltip>
) : (
@@ -100,19 +100,11 @@ export function ScrollContainer(props: ScrollContainerProps) {
}
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) {
for (const activeItem of container.querySelectorAll(active)) {
if (!isElementFullyVisibleInContainer(activeItem, container)) {
scrollToElementInContainer(activeItem, container, 'smooth');
return;
@@ -10,8 +10,6 @@ export type TooltipProps = {
side?: BaseTooltip.Positioner.Props['side'];
align?: BaseTooltip.Positioner.Props['align'];
sideOffset?: number;
/** Positions against this element instead of the trigger, e.g. when the trigger is transformed. */
anchor?: BaseTooltip.Positioner.Props['anchor'];
delay?: number;
disabled?: boolean;
disableHoverablePopup?: boolean;
@@ -33,7 +31,6 @@ export function Tooltip(
side,
align,
sideOffset = 4,
anchor,
delay,
disabled,
disableHoverablePopup,
@@ -72,7 +69,6 @@ export function Tooltip(
side={side}
align={align}
sideOffset={sideOffset}
anchor={anchor}
collisionPadding={8}
>
<BaseTooltip.Popup
+5 -8
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: 'حدث خطأ',
@@ -100,8 +96,9 @@ export const ar: TranslationLanguage = {
pdf_page_of: '${1} من ${2}',
pdf_mode_only_page: 'هذه الصفحة فقط',
pdf_mode_all: 'كل الصفحات',
pdf_limit_reached: 'تعذر إنشاء ملف PDF لـ ${1} صفحات، وتوقف الإنشاء عند ${2}.',
pdf_limit_reached_continue: 'التوسيع بإضافة ${1} صفحات أخرى.',
pdf_batch_range: 'عرض الصفحات ${1}–${2} من ${3}.',
pdf_batch_previous: 'الصفحات الـ ${1} السابقة',
pdf_batch_next: 'الصفحات الـ ${1} التالية',
more: 'المزيد',
link_tooltip_external_link: 'رابط خارجي إلى',
link_tooltip_email: 'إرسال بريد إلكتروني إلى',
+5 -9
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: 'Възникна грешка',
@@ -103,9 +99,9 @@ export const bg: TranslationLanguage = {
pdf_page_of: '${1} от ${2}',
pdf_mode_only_page: 'Само тази страница',
pdf_mode_all: 'Всички страници',
pdf_limit_reached:
'PDF файлът за ${1} страници не можа да бъде генериран, генерирането спря на ${2}.',
pdf_limit_reached_continue: 'Разширяване с още ${1} страници.',
pdf_batch_range: 'Показани са страници ${1}–${2} от ${3}.',
pdf_batch_previous: 'Предишни ${1} страници',
pdf_batch_next: 'Следващи ${1} страници',
more: 'Още',
link_tooltip_external_link: 'Външна връзка към',
link_tooltip_email: 'Изпращане на имейл до',
+5 -9
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ě',
@@ -101,9 +97,9 @@ export const cs: TranslationLanguage = {
pdf_page_of: '${1} z ${2}',
pdf_mode_only_page: 'Pouze tato stránka',
pdf_mode_all: 'Všechny stránky',
pdf_limit_reached:
'Nepodařilo se vygenerovat PDF pro ${1} stránek, generování se zastavilo na ${2}.',
pdf_limit_reached_continue: 'Rozšířit o dalších ${1} stránek.',
pdf_batch_range: 'Zobrazeny stránky ${1}–${2} z ${3}.',
pdf_batch_previous: 'Předchozích ${1} stránek',
pdf_batch_next: 'Dalších ${1} stránek',
more: 'Více',
link_tooltip_external_link: 'Externí odkaz na',
link_tooltip_email: 'Odeslat e-mail na',
+5 -8
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',
@@ -101,8 +97,9 @@ export const da: TranslationLanguage = {
pdf_page_of: '${1} af ${2}',
pdf_mode_only_page: 'Kun denne side',
pdf_mode_all: 'Alle sider',
pdf_limit_reached: 'Kunne ikke generere PDF for ${1} sider, genereringen stoppede ved ${2}.',
pdf_limit_reached_continue: 'Udvid med ${1} flere sider.',
pdf_batch_range: 'Viser sider ${1}–${2} af ${3}.',
pdf_batch_previous: 'Forrige ${1} sider',
pdf_batch_next: 'Næste ${1} sider',
more: 'Mere',
link_tooltip_external_link: 'Eksternt link til',
link_tooltip_email: 'Send e-mail til',

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