Compare commits

..

48 Commits

Author SHA1 Message Date
Tomek Gargula dd9179db11 changeset 2026-10-20 13:06:14 +02:00
Tomek Gargula 90f6a3cc89 Adjust tab panel and tab button padding 2026-10-20 13:06:10 +02:00
Peter White 9f940678b6 Update published-site Mermaid to 11.16.1 to match the editor (#4692) 2026-10-16 10:52:23 +00:00
Steven H 96626fea7c Point the admin toolbar insights button to /analytics (#4691)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:50:11 +01:00
Peter White d839d9dc57 Match site redirects whose source has non-ASCII characters (#4689) 2026-10-15 16:59:20 +02:00
Peter White 30fa152f0f Replace slapr with slack-pr-status (#4688) 2026-10-14 12:50:04 +02:00
Peter White 0540ebc1b9 Don't link page group breadcrumbs to their redirecting URL (#4686) 2026-10-14 10:31:26 +00:00
Peter White 0b515b0970 Fix 404 on Markdown pages when a theme is forced (#4685) 2026-10-14 11:46:58 +02:00
Peter White 65acba0b61 Fix Mermaid block diagrams and clipped labels on published sites (#4687) 2026-10-14 11:26:28 +02:00
Peter White f7dc7faa65 Reserve webframe size before hydration (#4683) 2026-10-14 08:25:57 +02:00
Peter White 03aa0f75f5 Render one shared Ask AI paragraph button (#4681) 2026-10-13 14:57:45 +02:00
Peter White a625112106 Skip the Ask AI paragraph button inside tables (#4680) 2026-10-13 13:40:20 +02:00
Brett Jephson 1bf3bd4294 Bump API version (#4679) 2026-10-13 10:55:32 +01:00
Peter White 9d62cb4bad Keep TOC scroll position when expanding a group (#4678) 2026-10-13 09:25:16 +02:00
Peter White 8bc381d852 Keep button labels readable inside colored hints (#4677) 2026-10-12 15:24:21 +02:00
Peter White a22923b56b Ask contributors to open issues instead of pull requests (#4676) 2026-10-12 12:06:51 +02:00
Peter White a73800e0ef Upgrade object-identity (#4675) 2026-10-12 11:22:25 +02:00
Steven H a0c1bdf58e Improve the Ask prompt for llms.txt (#4674) 2026-10-09 12:09:23 +01:00
Steven H d685660e1d Ensure we send the agent goal with the ask (#4673) 2026-10-08 20:51:24 +01:00
Peter White f1d5123c0b Support fr-ca, es-mx and es-419 site UI locales (RND-13260, RND-11280) (#4671) 2026-10-08 17:10:49 +02:00
conico974 679ea0706c Add support for visitor country hostnames via environment variable (#4668) 2026-10-08 11:35:18 +02:00
Peter White c366796756 Keep the embed open when switching section from the breadcrumbs (RND-12559) (#4670) 2026-10-08 11:26:04 +02:00
Peter White f35be52c80 Allow configuring the tab and page the Docs Embed opens on (RND-12456) (#4669) 2026-10-08 11:25:35 +02:00
conico974 848a39d1c8 Send visitor country in API requests for specific environments (#4667) 2026-10-07 18:08:57 +02:00
Peter White 9f447aab29 Open search in the current space from scoped search buttons (#4666) 2026-10-07 17:04:19 +02:00
Nolann B. 552538a0d9 Record proxied sites' markdown, llms.txt and RSS requests under their public URL (#4660) 2026-10-07 11:53:03 +02:00
Peter White 5c4a808ba8 Stop self-referencing nullable OpenAPI schemas from recursing when expanded (#4665) 2026-10-07 11:38:46 +02:00
Zeno Kapitein ad6262b6f9 Scope the site preview cookie to the preview route so customization/theme persists on navigation (RND-12191) (#4481)
Co-authored-by: Peter White <1788320+peterwhite@users.noreply.github.com>
2026-10-07 09:53:30 +02:00
Tomek 2137c2df7b Track Ask AI entry point in analytics (#4646) 2026-10-06 19:33:34 +02:00
Peter White 990e702035 Clarify not-found copy for login-protected pages (#4663) 2026-10-06 16:58:58 +02:00
Peter White 3ed448fed7 Keep ?ask out of the search field while integration assistants load (#4661) 2026-10-06 14:11:41 +02:00
conico974 4856a1f744 Add support for resolving missing page paths and redirecting to markdown versions (#4657) 2026-10-06 13:54:16 +02:00
Peter White ac843657ec Restore Next.js dev-mode OOM patch for 16.3.8 (#4662) 2026-10-06 13:33:50 +02:00
Brett Jephson b1102fcebe Prepare DocumentMarkColor to work with optional properties (#4659) 2026-10-05 17:55:22 +01:00
conico974 7996c362c1 Bump Next and OpenNext (#4658) 2026-10-05 15:48:35 +02:00
Steven H c7c1b79788 Improve the prompt asking agents for feedback on GitBook-powered docs (#4654) 2026-10-02 18:38:56 +01:00
Taran Vohra dff0c7903e Resolve cross-space repository page URLs when rendering sites (#4619)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:31:20 +05:30
Tomek ffebd1790e Extract page title utility and add SEO title support (#4647)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Co-authored-by: Nolann Biron <biron.nolann@gmail.com>
Co-authored-by: Nolann B. <100787331+nolannbiron@users.noreply.github.com>
2026-10-02 14:07:15 +02:00
Tomek 0e0085e49a Fix sidebar group titles being clipped while scrolling (#4623)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 14:31:00 +02:00
Tomek 8e131693ea Auto-scroll TOC to active item when it's not visible (#4653) 2026-10-01 09:14:01 +00:00
Peter White 96325161be Lazy-load Loom embeds so pages with many of them don't stay blank (RND-13147) (#4652) 2026-09-30 14:18:29 +02:00
Surai 9f5290bccc Fix Ask AI submissions when search state is empty (#4651) 2026-09-30 09:50:39 +02:00
Peter White 61992424af Mark text over a background cover in the first frame after hydration (#4650) 2026-09-30 08:21:31 +02:00
Peter White 380af10236 Send previousUrl with site insights events (#4640) 2026-09-29 16:43:14 +00:00
Peter White 0911abc55c Fix page cover image jumping on load (#4648) 2026-09-29 18:11:03 +02:00
Tomek d8dc59b1a9 Improve dark theme contrast for Mermaid edge labels (#4645)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-29 14:02:07 +02:00
Surai d04f8bc05e Update trademark landing page (#4644) 2026-09-29 11:13:52 +02:00
conico974 576a781a8f Bump versions of @opennextjs/aws and @opennextjs/cloudflare (#4643) 2026-09-29 10:51:30 +02:00
180 changed files with 3097 additions and 862 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Stop the `?ask` query briefly appearing in the search field before an integration assistant opens.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Track which Ask AI entry point opened the chat in ask view analytics.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Recolor text over a background page cover in the first frame after hydration.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Update API
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix table of contents scrolling back to the current page when expanding another group.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Keep links working when their target page is deleted and re-created at the same path, by falling back to the path recorded on the link.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Automatically resolve GitHub and GitLab page links to matching pages in the same published site, including cross-space links imported before their target page was available.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep the docs embed open when switching section, variant or language.
+6
View File
@@ -0,0 +1,6 @@
---
"@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
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix page cover image jumping on load
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Use the DocumentTextColor type defined in API schema.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix low-contrast button labels inside success, warning and danger hints.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Agents can now send a goal with any question they ask on the Markdown version of a page.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix inline Ask AI inputs and buttons doing nothing before search is opened.
+5
View File
@@ -0,0 +1,5 @@
---
"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
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Send the previous page's URL with site insights events so broken links can be traced to the page linking to them.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix Loom videos staying blank on pages with many Loom embeds by lazy-loading them.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Update Mermaid to 11.16.1 to match the editor, adding support for doughnut charts.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix Mermaid block diagrams falling back to a code block on published sites.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix long Mermaid labels being clipped instead of wrapping at some browser zoom levels.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Improve dark theme contrast for Mermaid edge labels.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Improve the prompt for agents to ask questions.
+6
View File
@@ -0,0 +1,6 @@
---
"@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
---
Use the page's tag title, when set, for the HTML `<title>` of published pages.
+5
View File
@@ -0,0 +1,5 @@
---
"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
@@ -0,0 +1,5 @@
---
"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
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Stop page group breadcrumbs from linking to a URL that redirects to the group's first page.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Scroll the table of contents to the active page after client-side navigation.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Site redirects whose source path has accented or non-Latin characters now redirect visitors instead of returning 404.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Show site UI in French or Spanish for spaces set to Canadian French, Mexican Spanish or Latin American Spanish.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Restore the Next.js dev-mode OOM patch that was silently dropped by the 16.3.8 upgrade.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Let search buttons open search limited to the current space.
+5
View File
@@ -0,0 +1,5 @@
---
"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.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Render a single Ask AI paragraph button that moves to the hovered paragraph, instead of one hidden button per paragraph.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the first item of a sidebar page group sometimes appearing cut off after navigating.
+5
View File
@@ -0,0 +1,5 @@
---
"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.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix 404 on Markdown pages (View as Markdown) in site previews and when a theme is forced with `?theme=`.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Adjust tab panel and tab button padding so tabbed content has more breathing room.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the admin toolbar insights button to link to the site's analytics page.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Point the "Powered by GitBook" trademark link to gitbook.com/powered-by.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Upgrade object-identity to preserve value types when caching object arguments.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Read the production hostnames that receive the visitor country from the `GITBOOK_VISITOR_COUNTRY_HOSTNAMES` build-time env variable.
@@ -0,0 +1,6 @@
---
"@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.
+46 -70
View File
@@ -1,113 +1,89 @@
# Welcome to GitBook's contributing guide!
# Contributing to GitBook
> _For help, support, feature requests, and product questions - head to our [GitHub Community](https://github.com/orgs/GitbookIO/discussions) 🤖_
> _For help, support and product questions, head to our [GitHub Community](https://github.com/orgs/GitbookIO/discussions)._
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.
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.
## Types of Contributions
## Ways to contribute
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.
- **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.
### Add a feature
## Issues instead of pull requests
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).
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.
### Create a new issue
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.
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
A good issue includes:
- Descriptive
- Thoughtful
- Organized
- 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
We recommend adding as many relevant links, minimal reproductions of the issue, and other materials that will help our team solve the issue fast.
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.
### Solve an issue
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.
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.
If you open a pull request anyway, we'll close it and point you here. Nothing personal, and we'd still love the issue.
### Documentation
## Reach out
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.
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.
## Contributing
Spotted a wrong or missing translation? Open an issue with the string and the wording you'd suggest.
### Make changes locally
## Using AI
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.
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.
#### 1. Fork the repository
## Running locally
##### Using GitHub Desktop:
You don't need a local setup to open an issue, but it can help you reproduce a problem or self-host the renderer.
- [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)!
### Prerequisites
##### Using the command line:
- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`)
- [Bun](https://bun.sh/) (see "packageManager" in `package.json`)
- [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.
### Setup
#### 2. Create a working branch and start with your changes
1. Use the project's version of Node:
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 using Bun:
2. Install dependencies:
```bash
bun install
```
3. Start the development server:
```bash
bun dev
```
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
Other commands:
#### 4. Preview your changes
- `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
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/url`.
### Preview a site
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.
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.
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.
The site must be [published publicly](https://gitbook.com/docs/published-documentation/publish-your-content-as-a-docs-site) to render locally.
### Commit your update
## License
[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.
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).
@@ -78,6 +78,11 @@ 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
@@ -0,0 +1,15 @@
# 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
@@ -1,88 +0,0 @@
# 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,6 +10,8 @@ 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:
@@ -31,6 +33,8 @@ 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
@@ -0,0 +1,33 @@
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
@@ -1,30 +0,0 @@
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 more about the workflow on adding your first Pull Request.
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.
### 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/). We welcome all additional translations for the UI.
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.
#### Bugs
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.
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.
## 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 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._
> _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._
>
> 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 contribute to our product directly 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 share your ideas with us 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.
+33 -57
View File
@@ -129,8 +129,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.1.3",
"@opennextjs/cloudflare": "1.20.5",
"@opennextjs/aws": "4.1.7",
"@opennextjs/cloudflare": "1.20.8",
"@panzoom/panzoom": "^4.6.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -155,16 +155,16 @@
"mdast-util-gfm": "^3.1.0",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.17",
"mermaid": "^11.14.0",
"mermaid": "11.16.1",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.3.6",
"next": "^16.3.8",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
"object-identity": "^0.1.2",
"object-identity": "^0.2.1",
"openapi-types": "^12.1.3",
"p-map": "^7.0.3",
"p-retry": "^8.0.0",
@@ -342,11 +342,11 @@
},
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"next@16.3.6": "patches/next@16.3.6.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",
"@opennextjs/aws": "4.1.5",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"axios": "1.8.4",
@@ -356,7 +356,7 @@
},
"catalog": {
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.202.0",
"@gitbook/api": "0.206.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -572,15 +572,7 @@
"@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/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=="],
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
@@ -728,7 +720,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.202.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-qhrjEQbNNmCljR0AgP79+BsVh9yelh9TIBg9X3TyYx5a3lJwDiT3sw1ND9DFQSxGHp8hH5w+20KJHn/IDpTbaw=="],
"@gitbook/api": ["@gitbook/api@0.206.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ctgnDSQ26ZeZvdU/Zb1xxU97yMquKnhGiA0QblAIzKUmfi/3aYmWYHAwXwvEg0wtBrEtrjzme9pXgX/ACWnANA=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -886,29 +878,29 @@
"@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.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="],
"@mermaid-js/parser": ["@mermaid-js/parser@1.2.1", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw=="],
"@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=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
"@next/env": ["@next/env@16.3.6", "", {}, "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ=="],
"@next/env": ["@next/env@16.3.8", "", {}, "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg=="],
"@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="],
@@ -944,9 +936,9 @@
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.5", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3" }, "bin": { "open-next": "dist/index.js" } }, "sha512-41LNGtS5R5SCfn9zFxHx9QKJIRnmf1uZH+jonHkUMlRKER0bSmUSGjK6GJF7e50XdLXFpTHzaqgRvGmzvr9Btw=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.7", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.27 <16 || >=16.3.8" }, "bin": { "open-next": "dist/index.js" } }, "sha512-ELezSARrTfxp/76cz41eQjki1AV0CuBUKOznU3w/tOk7i87x867RkkYho+gDkog9gpnGzefHdJu463o9VaabNQ=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.5", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.3", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-Y4qCnHTYMa8waEHvf7iqDJuiresuREp0zithSZ8hT33Wn9WyvOUMBM0SWNH961Wv7ynHRdxAJ0ggmnQ3aSdzdg=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.8", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.7", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.27 <16 || >=16.3.8", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-7rrsBqd234GBBnHITobS0gagT4/fSfHJwm+ogYTyUb7ubg3PGC06biJgL9l4DZabFeu/ucqvml80sBr5torBgg=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -1752,10 +1744,6 @@
"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=="],
@@ -1848,7 +1836,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.33.1", "", {}, "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ=="],
"cytoscape": ["cytoscape@3.34.4", "", {}, "sha512-msY99tMY8iWTg+sIPGfSqDcPNOt4xcj+v/K/7qUFaCUxxlqnMkUYLBjRUzn9W5CU/R2hrmtJa5+UtovrEM2ctg=="],
"cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="],
@@ -1924,7 +1912,7 @@
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="],
"dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -1974,7 +1962,7 @@
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
"dompurify": ["dompurify@3.4.17", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w=="],
"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=="],
@@ -2032,6 +2020,8 @@
"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=="],
@@ -2402,7 +2392,7 @@
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
"katex": ["katex@0.16.25", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q=="],
"katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
"keyv": ["keyv@5.3.2", "", { "dependencies": { "@keyv/serialize": "^1.0.3" } }, "sha512-Lji2XRxqqa5Wg+CHLVfFKBImfJZ4pCSccu9eVWK6w4c2SDFLd8JAn1zqTuSFnsxb7ope6rMsnIHfp+eBbRBRZQ=="],
@@ -2414,8 +2404,6 @@
"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=="],
@@ -2540,7 +2528,7 @@
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"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=="],
"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=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
@@ -2650,7 +2638,7 @@
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
"next": ["next@16.3.6", "", { "dependencies": { "@next/env": "16.3.6", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.6", "@next/swc-darwin-x64": "16.3.6", "@next/swc-linux-arm64-gnu": "16.3.6", "@next/swc-linux-arm64-musl": "16.3.6", "@next/swc-linux-x64-gnu": "16.3.6", "@next/swc-linux-x64-musl": "16.3.6", "@next/swc-win32-arm64-msvc": "16.3.6", "@next/swc-win32-x64-msvc": "16.3.6", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw=="],
"next": ["next@16.3.8", "", { "dependencies": { "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.8", "@next/swc-darwin-x64": "16.3.8", "@next/swc-linux-arm64-gnu": "16.3.8", "@next/swc-linux-arm64-musl": "16.3.8", "@next/swc-linux-x64-gnu": "16.3.8", "@next/swc-linux-x64-musl": "16.3.8", "@next/swc-win32-arm64-msvc": "16.3.8", "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
@@ -2678,7 +2666,7 @@
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"object-identity": ["object-identity@0.1.2", "", {}, "sha512-Px5puVllX5L2aBjbcfXpiG5xXeq6OE8RckryTeP2Zq+0PgYrCGJXmC6LblWgknKSJs11Je2W4U2NOWFj3t/QXQ=="],
"object-identity": ["object-identity@0.2.1", "", {}, "sha512-itX2N4m0IvaLhFZMi9YkW4nFML6FshKCCpnn6iKJZVIkCNM/ZRaG4t1bzD2ncaW4r6cHJa59XY+7oZAH7tg8tA=="],
"object-inspect": ["object-inspect@1.13.2", "", {}, "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g=="],
@@ -2934,8 +2922,6 @@
"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=="],
@@ -3222,18 +3208,6 @@
"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=="],
@@ -3696,6 +3670,8 @@
"@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=="],
+4 -4
View File
@@ -19,8 +19,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.27.3",
"axios": "1.8.4",
"@opennextjs/aws": "4.1.5"
"axios": "1.8.4"
},
"private": true,
"scripts": {
@@ -49,7 +48,7 @@
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.202.0",
"@gitbook/api": "0.206.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -68,6 +67,7 @@
"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.6": "patches/next@16.3.6.patch"
"next@16.3.8": "patches/next@16.3.8.patch",
"mermaid@11.16.1": "patches/mermaid@11.16.1.patch"
}
}
+30
View File
@@ -54,6 +54,7 @@ GitBook('configure', {
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
},
tabs: ['assistant', 'search', 'docs'],
defaultPage: '/getting-started',
actions: [
{
icon: 'circle-question',
@@ -107,6 +108,7 @@ frame.clearChat();
// Configure the embed (see Configuration section for all options)
frame.configure({
tabs: ['assistant', 'search', 'docs'],
defaultPage: '/getting-started',
actions: [
{
icon: 'circle-question',
@@ -142,6 +144,7 @@ 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?']}
@@ -249,6 +252,33 @@ 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,6 +62,12 @@ 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,6 +27,8 @@ export function GitBookFrame(props: GitBookFrameProps) {
suggestions = [],
tools = [],
tabs = ['assistant', 'search', 'docs'],
defaultTab,
defaultPage,
trademark = true,
closeButton = false,
assistantName,
@@ -50,6 +52,8 @@ export function GitBookFrame(props: GitBookFrameProps) {
useEffect(() => {
gitbookFrame?.configure({
tabs,
defaultTab,
defaultPage,
actions,
greeting,
suggestions,
@@ -65,6 +69,8 @@ export function GitBookFrame(props: GitBookFrameProps) {
suggestions,
tools,
tabs,
defaultTab,
defaultPage,
closeButton,
trademark,
assistantName,
+24
View File
@@ -1125,6 +1125,30 @@ 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,6 +95,8 @@ 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,
+5 -5
View File
@@ -20,8 +20,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.1.3",
"@opennextjs/cloudflare": "1.20.5",
"@opennextjs/aws": "4.1.7",
"@opennextjs/cloudflare": "1.20.8",
"@panzoom/panzoom": "^4.6.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -46,16 +46,16 @@
"mdast-util-gfm": "^3.1.0",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.17",
"mermaid": "^11.14.0",
"mermaid": "11.16.1",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.3.6",
"next": "^16.3.8",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
"object-identity": "^0.1.2",
"object-identity": "^0.2.1",
"openapi-types": "^12.1.3",
"p-map": "^7.0.3",
"p-retry": "^8.0.0",
@@ -1,5 +1,6 @@
import { build } from 'bun';
import { mkdir, rename, rm } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, rm } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -12,14 +13,20 @@ 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 change.
// The runtime URL is served as immutable, it has to change whenever the bundled versions or patch change.
if (
MERMAID_RUNTIME_PATH !==
`mermaid/mermaid@${mermaidPackage.version}-zenuml@${zenumlPackage.version}.mjs`
`mermaid/mermaid@${mermaidPackage.version}${mermaidPatchSuffix}-zenuml@${zenumlPackage.version}.mjs`
) {
throw new Error(
'Update MERMAID_RUNTIME_PATH for the installed mermaid and @mermaid-js/mermaid-zenuml versions'
'Update MERMAID_RUNTIME_PATH for the installed mermaid and @mermaid-js/mermaid-zenuml versions and the mermaid patch'
);
}
+12 -7
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 { useAIChatController, useAIChatState } from '.';
import { type AskAITrigger, useAIChatController, useAIChatState } from '.';
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
import { useIntegrationAssistants } from '../Integrations';
import { useSetSearchState } from '../Search/useSearch';
@@ -26,7 +26,8 @@ export type AIConfig = {
};
};
export type Assistant = Omit<GitBookAssistant, 'icon'> & {
export type Assistant = Omit<GitBookAssistant, 'icon' | 'open'> & {
open: (query?: string, trigger?: AskAITrigger) => void;
/**
* Unique identifier for the assistant. Generated automatically using Crypto.randomUUID().
* @example '123e4567-e89b-12d3-a456-426614174000'
@@ -102,8 +103,8 @@ export function useAI(): AIContext {
className="size-text-lg"
/>
),
open: (query?: string) => {
chatController.open();
open: (query?: string, trigger?: AskAITrigger) => {
chatController.open(trigger);
if (query) {
chatController.postMessage({ message: query });
}
@@ -119,9 +120,13 @@ export function useAI(): AIContext {
icon: <AISearchIcon />,
open: (query?: string) => {
if (query) {
setSearchState((prev) =>
prev ? { ...prev, query: null, ask: query, open: true } : null
);
setSearchState((prev) => ({
...prev,
query: null,
ask: query,
scope: prev?.scope ?? 'default',
open: true,
}));
}
},
pageAction: false,
@@ -11,6 +11,7 @@ import {
type AIStreamResponseToolCallPending,
type AIToolCallResult,
SiteInsightsDisplayContext,
type SiteInsightsEventAskView,
} from '@gitbook/api';
import { getInsightsSession, useTrackEvent } from '../Insights';
@@ -60,6 +61,8 @@ export type AIChatStatus =
| 'error'
| 'confirm';
export type AskAITrigger = NonNullable<SiteInsightsEventAskView['trigger']>;
export type AIChatState = {
/**
* If true, the chat is open.
@@ -160,7 +163,7 @@ type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
export type AIChatController = {
/** Open the dialog */
open: () => void;
open: (trigger?: AskAITrigger) => void;
/** Close the dialog */
close: () => void;
/** Post a message to the session */
@@ -302,22 +305,28 @@ 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(() => {
setIntercomLauncherHidden(true);
const onOpen = React.useCallback(
(trigger?: AskAITrigger) => {
setIntercomLauncherHidden(true);
if (!renderMessageOptions?.asEmbeddable) {
trackEvent({ type: 'ask_view', trigger });
}
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]);
notify(eventsRef.current.get('open'), {});
},
[setSearchState, trackEvent, renderMessageOptions?.asEmbeddable]
);
// Close AI chat and clear ask parameter
const onClose = React.useCallback(() => {
@@ -24,7 +24,6 @@ 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';
@@ -68,16 +67,6 @@ 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()}
onClick={() => assistant.open(undefined, 'site')}
>
{showLabel ? t(language, 'ask') : null}
</Button>
@@ -1,29 +1,58 @@
'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 { type ClassValue, tcls } from '@/lib/tailwind';
import { tcls } from '@/lib/tailwind';
/**
* 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 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.
*
* 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.
* 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.
*/
export function AskAIParagraphButton(props: { content: string; className?: ClassValue }) {
const { content, className } = props;
export function AskAIParagraphButton() {
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 = content.trim();
const text = getParagraphText(target).trim();
if (!text) {
return;
}
@@ -33,12 +62,12 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
id: `text-${fnv1a(text, { size: 32 })}`,
content: text,
});
chatController.open();
chatController.open('hover');
chatController.setDraft(tString(language, 'ai_chat_paragraph_draft'));
chatController.focus();
};
return (
return createPortal(
<div
className={tcls(
// Sit in the left margin, flush against the paragraph so there is no hover gap.
@@ -52,13 +81,7 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
'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',
// 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
'not-pointer-fine:hidden'
)}
>
<Button
@@ -75,6 +98,37 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
'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>
</div>,
target
);
}
/**
* 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();
chatController.open('selection');
chatController.focus();
clear();
};
@@ -317,7 +317,7 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
<ToolbarButton
title="Open insights"
href={getToolbarHref({
href: `${site.urls.app}/insights`,
href: `${site.urls.app}/analytics`,
siteId: site.id,
buttonId: 'insights',
})}
@@ -247,11 +247,15 @@ async function renderMermaidDiagram(args: {
const { source, id, darkMode, mermaidRuntimeURL } = args;
const { mermaid } = await loadMermaid(mermaidRuntimeURL);
// Mermaid's default dark edge label pill only reaches 4.43:1 contrast, below WCAG AA.
const themeVariables = darkMode ? { edgeLabelBackground: '#3a3a3a' } : undefined;
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
darkMode,
theme: darkMode ? 'dark' : undefined,
themeVariables,
});
const renderContainer = createMermaidRenderContainer();
@@ -1 +1 @@
export const MERMAID_RUNTIME_PATH = 'mermaid/mermaid@11.14.0-zenuml@0.2.2.mjs';
export const MERMAID_RUNTIME_PATH = 'mermaid/mermaid@11.16.1-patch.78ecb855-zenuml@0.2.2.mjs';
@@ -37,7 +37,10 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
<>
<div
dangerouslySetInnerHTML={{
__html: embed.html,
__html:
context.mode !== 'print' && shouldLazyLoad(block.data.url)
? lazyLoadIframes(embed.html)
: embed.html,
}}
data-visual-test="blackout"
/>
@@ -73,6 +76,23 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
);
}
/**
* Pages with many Loom embeds exhaust the browser's request budget
* (ERR_INSUFFICIENT_RESOURCES) when every player loads at once, leaving some blank.
*/
function lazyLoadIframes(html: string): string {
return html.replace(/<iframe\b(?![^>]*\bloading=)/gi, '<iframe loading="lazy"');
}
function shouldLazyLoad(url: string): boolean {
try {
const { hostname } = new URL(url);
return hostname === 'loom.com' || hostname.endsWith('.loom.com');
} catch {
return false;
}
}
/**
* Create an integration block with an unfurl action from the GitBook Embed response.
*/
@@ -135,10 +135,10 @@ const HINT_STYLES: {
header: 'bg-warning-active',
body: [
'text-neutral-strong',
'links-default:[&_a]:text-warning',
'links-default:[&_a:hover]:text-warning-strong',
'links-default:[&_a]:decoration-warning/6',
'links-accent:[&_a]:decoration-warning',
'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',
'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]:text-danger',
'links-default:[&_a:hover]:text-danger-strong',
'links-default:[&_a]:decoration-danger/6',
'links-accent:[&_a]:decoration-danger',
'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',
'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]:text-success',
'links-default:[&_a:hover]:text-success-strong',
'links-default:[&_a]:decoration-success/6',
'links-accent:[&_a]:decoration-success',
'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',
'decoration-success/6',
'[&_.can-override-bg]:bg-success-active',
'[&_.can-override-text]:text-success-strong',
@@ -13,9 +13,14 @@ const INPUT_SIZE_MAP: Record<NonNullable<ButtonProps['size']>, 'small' | 'medium
};
export function InlineActionButton(
props: { action: 'ask' | 'search'; query?: string } & { buttonProps: ButtonProps } // TODO: Type this properly: Pick<api.DocumentInlineButton, 'action' | 'query'> & { buttonProps: ButtonProps }
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 }
) {
const { action, query, buttonProps } = props;
const { action, query, scope, buttonProps } = props;
const { assistants } = useAI();
const chatState = useAIChatState();
@@ -29,7 +34,7 @@ export function InlineActionButton(
setSearchState((prev) => ({
...prev,
ask: null,
scope: 'default',
scope: scope === 'space' ? 'current' : 'default',
query: value ?? query,
open: true,
}));
@@ -47,6 +47,11 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
<InlineActionButton
action={inline.data.action.action}
query={inline.data.action.query ?? ''}
scope={
inline.data.action.action === 'search'
? inline.data.action.scope
: undefined
}
buttonProps={buttonProps}
/>
);
@@ -52,8 +52,8 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const anchorElement = (
<InlineLinkAnchor
href={resolved.href}
contentRef={inline.data.ref}
isExternal={inline.data.ref.kind === 'url'}
contentRef={resolved.resolvedRef ?? inline.data.ref}
isExternal={(resolved.resolvedRef ?? inline.data.ref).kind === 'url'}
>
{inlinesElement}
</InlineLinkAnchor>
@@ -121,7 +121,7 @@ function InlineLinkTooltipWrapper(props: {
let breadcrumbs = resolved.ancestors ?? [];
const isMailto = resolved.href.startsWith('mailto:');
const isExternal = inline.data.ref.kind === 'url';
const isExternal = (resolved.resolvedRef ?? inline.data.ref).kind === 'url';
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
if (isMailto) {
@@ -52,6 +52,14 @@
@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,12 +4,11 @@ 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, ...contextProps } = props;
const { block, style, ancestorBlocks, ...contextProps } = props;
const { context } = contextProps;
const paragraph = (
@@ -28,6 +27,8 @@ 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;
@@ -38,6 +39,8 @@ 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,
@@ -45,7 +48,6 @@ export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
)}
>
{paragraph}
<AskAIParagraphButton content={text} />
</div>
);
}
@@ -50,6 +50,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
tag: Tag = 'div',
ariaLabelledBy,
block,
ancestorBlocks,
document,
record,
column,
@@ -151,7 +152,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
<Blocks
tag={Tag}
document={document}
ancestorBlocks={[]}
ancestorBlocks={[...ancestorBlocks, block]}
nodes={fragment.nodes}
style={[
'blocks',
@@ -130,7 +130,7 @@ const TabPanel = memo(function TabPanel(props: {
aria-labelledby={getTabButtonId(tab.id)}
className="scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*20)]"
>
<div className="p-4">{tab.body}</div>
<div className="px-8 py-6">{tab.body}</div>
</div>
);
});
@@ -317,7 +317,7 @@ function TabButton(
{...rest}
type="button"
className={tcls(
'relative inline-flex max-w-full items-center gap-1.5 px-3.5 py-2 font-medium text-sm transition-[color]',
'relative inline-flex max-w-full items-center gap-1.5 px-4 py-2.5 font-medium text-sm transition-[color]',
props.className
)}
/>
@@ -117,8 +117,8 @@ function Color(props: MarkedLeafProps<DocumentMarkColor>) {
return (
<span
className={tcls([
textColorToStyle[mark.data.text],
backgroundColorToStyle[mark.data.background],
textColorToStyle[mark.data.text ?? 'default'],
backgroundColorToStyle[mark.data.background ?? 'default'],
])}
>
{children}
@@ -1,6 +1,6 @@
import { expect, it } from 'bun:test';
import type { DocumentBlockParagraph, JSONDocument } from '@gitbook/api';
import type { DocumentBlockParagraph, DocumentBlockTable, JSONDocument } from '@gitbook/api';
import { isBlockOffscreen } from './isBlockOffscreen';
@@ -42,3 +42,31 @@ 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,6 +18,11 @@ 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,9 +61,7 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
}
trackEvent(
{
type: 'ask_view',
},
{ type: 'ask_view', trigger: 'embed' },
{
pageId: null,
displayContext: api.SiteInsightsDisplayContext.Embed,
@@ -97,6 +97,7 @@ export async function EmbeddableDocsPage(
) ?? context.siteSpace
}
siteSpaces={variants.translations}
asEmbeddable
className="my-1.5 ml-2 self-start"
/>
) : null}
@@ -125,6 +126,7 @@ export async function EmbeddableDocsPage(
) ?? context.siteSpace
}
siteSpaces={variants.translations}
asEmbeddable
className="max-md:[&_.button-content]:block"
/>
) : null
@@ -137,6 +139,7 @@ export async function EmbeddableDocsPage(
context={context}
siteSpace={context.siteSpace}
siteSpaces={variants.generic}
asEmbeddable
className="w-full px-3"
/>
) : null}
@@ -9,6 +9,7 @@ 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';
@@ -23,6 +24,17 @@ 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
@@ -66,6 +78,15 @@ 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) {
@@ -83,29 +104,43 @@ export function EmbeddableIframeAPI(props: { baseURL: string }) {
break;
}
case 'configure': {
embeddableConfiguration.setState(message.settings);
const { settings } = message;
embeddableConfiguration.setState(settings);
integrationsAssistantTools.setState({
tools: message.settings.tools,
tools: 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': {
// 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}`));
navigate(resolvePageHref(message.pagePath, baseURL));
break;
}
case 'navigateToAssistant': {
@@ -132,7 +167,7 @@ export function useEmbeddableConfiguration<T = GitBookEmbeddableConfiguration>(
export function useEmbeddableTabs() {
const configuredTabs = useEmbeddableConfiguration((state) => state.tabs);
return configuredTabs.length > 0 ? configuredTabs : ['assistant', 'search', 'docs'];
return configuredTabs.length > 0 ? configuredTabs : DEFAULT_EMBEDDABLE_TABS;
}
export function useEmbeddableLinkContext() {
@@ -200,6 +235,7 @@ 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();
@@ -228,7 +264,7 @@ export function EmbeddableIframeTabs(props: {
key: 'docs',
label: siteTitle,
icon: 'book-open',
href: `${baseURL}/page/`,
href: docsHomeHref ?? `${baseURL}/page/`,
}
: null,
].filter((tab) => tab !== null);
@@ -1,6 +1,8 @@
import type { CustomizationDefaultThemeMode } from '@gitbook/api';
import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import { AskAIParagraphButton } from '../AIChat';
import { LoadIntegrations } from '../Integrations';
import { NavigationLoader } from '../primitives/NavigationLoader';
import { SpaceLayoutServerContext } from '../SpaceLayout';
import { Trademark } from '../TableOfContents/Trademark';
@@ -76,6 +78,7 @@ export async function EmbeddableRootLayout({
}}
>
<NavigationLoader />
<AskAIParagraphButton />
<VisibilityProvider className="fixed inset-0 flex flex-col">
{children}
{context.customization.trademark.enabled ? (
@@ -93,6 +96,8 @@ export async function EmbeddableRootLayout({
/>
</SpaceLayoutServerContext>
</EmbeddableAIContextProvider>
{/* Embeds load no integrations; this marks them loaded so search stops waiting. */}
<LoadIntegrations />
</SiteLayoutClientContexts>
</CustomizationRootLayout>
);
@@ -0,0 +1,39 @@
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();
});
});
@@ -0,0 +1,17 @@
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,9 +2,10 @@ 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, getSiteSpaceURL } from '@/lib/sites';
import { getLocalizedTitle } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
// Memoized regex for checking if a string starts with an emoji
@@ -20,9 +21,18 @@ 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, ...buttonProps } = props;
const {
context,
siteSpace,
siteSpaces,
className,
asEmbeddable = false,
...buttonProps
} = props;
const currentLanguage = context.locale;
const dropdownClassName = tcls(
@@ -33,7 +43,7 @@ export function SpacesDropdown(
const slimSpaces = siteSpaces.map((siteSp) => ({
id: siteSp.id,
title: getLocalizedTitle(siteSp, currentLanguage),
url: getSiteSpaceURL(context, siteSp),
url: getTargetURLForSiteSpace(context, siteSp, asEmbeddable),
isActive: siteSp.id === siteSpace.id,
spaceId: siteSp.space.id,
}));
@@ -57,9 +67,10 @@ export function TranslationsDropdown(
siteSpace: SiteSpace;
siteSpaces: SiteSpace[];
className?: string;
asEmbeddable?: boolean;
} & ButtonProps
) {
const { context, siteSpace, siteSpaces, className, ...buttonProps } = props;
const { context, siteSpace, siteSpaces, className, asEmbeddable, ...buttonProps } = props;
const title = getLocalizedTitle(siteSpace, context.locale);
const hasEmojiPrefix = startsWithEmoji(title);
@@ -70,6 +81,7 @@ export function TranslationsDropdown(
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
asEmbeddable={asEmbeddable}
variant="blank"
className={tcls(
'bg-transparent lg:max-w-64 max-md:[&_.button-content]:hidden',
@@ -4,6 +4,7 @@ import * as React from 'react';
import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
import type * as api from '@gitbook/api';
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi';
import { type CurrentContentContext, useCurrentContent } from '../hooks';
@@ -75,12 +76,15 @@ export function InsightsProvider(props: InsightsProviderProps) {
[pathname: string]:
| {
url: string;
previousUrl: string | null;
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext?: InsightsEventPageContext;
}
| undefined;
}>({});
// Href of the last page an event was tracked on; `undefined` until the first event.
const lastUrlRef = React.useRef<string | null | undefined>(undefined);
/**
* Synchronously flush all the pending events.
@@ -105,6 +109,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
allEvents.push(
...transformEvents({
url: eventsForPathname.url,
previousUrl: eventsForPathname.previousUrl,
events: eventsForPathname.events,
context: currentContent,
pageContext: eventsForPathname.pageContext,
@@ -154,6 +159,9 @@ export function InsightsProvider(props: InsightsProviderProps) {
) => {
const pathname = window.location.pathname;
const previous = eventsRef.current[pathname];
const lastUrl =
lastUrlRef.current === undefined ? getSameOriginReferrer() : lastUrlRef.current;
lastUrlRef.current = window.location.href;
eventsRef.current[pathname] = {
// An explicitly-provided context wins so page-scoped events (e.g. feedback) can
// attribute to their page even when the pathname's ambient context has none — such
@@ -161,6 +169,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
// context keep the stored one.
pageContext: ctx ?? previous?.pageContext,
url: previous?.url ?? window.location.href,
previousUrl: previous ? previous.previousUrl : lastUrl,
events: [
...(previous?.events ?? []),
{
@@ -214,6 +223,18 @@ export function useTrackEvent(): TrackEventCallback {
return React.useContext(InsightsContext);
}
/**
* The referrer when it's another page of this origin, so a full page load (e.g. an absolute link
* to a missing page) still records the page it came from.
*/
function getSameOriginReferrer(): string | null {
if (document.referrer === window.location.href || !URL.canParse(document.referrer)) {
return null;
}
const referrer = new URL(document.referrer);
return referrer.origin === window.location.origin ? referrer.href : null;
}
/**
* Post the events to the server.
*/
@@ -240,6 +261,7 @@ function sendEvents(args: { eventUrl: string; events: api.SiteInsightsEvent[] })
*/
function transformEvents(input: {
url: string;
previousUrl: string | null;
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext: InsightsEventPageContext;
@@ -258,6 +280,11 @@ function transformEvents(input: {
const location: api.SiteInsightsEventLocation = {
url: input.url,
// The embed's navigation is between its own tabs, not pages of the site.
previousUrl:
input.pageContext.displayContext === SiteInsightsDisplayContext.Embed
? null
: input.previousUrl,
siteSection: input.context.siteSectionId ?? null,
siteSpace: input.context.siteSpaceId ?? null,
space: input.context.spaceId,
@@ -147,6 +147,10 @@ export function useCustomCookieBanner(): CustomCookieBannerStore {
export function LoadIntegrations() {
React.useEffect(() => {
return whenIntegrationsReady(() => {
// Integrations must only receive `load` once, even if this is mounted twice.
if (integrationsStore.getState().loaded) {
return;
}
dispatchGitBookIntegrationEvent('load');
integrationsStore.setState({ loaded: true });
});
@@ -67,7 +67,7 @@ export function ActionOpenAssistant(props: {
href: page.href,
});
}
assistant.open();
assistant.open(undefined, 'page');
}}
/>
);
@@ -1,4 +1,8 @@
import type { JSONDocument, RevisionPageDocument, SiteInsightsDisplayContext } from '@gitbook/api';
import {
type JSONDocument,
type RevisionPageDocument,
SiteInsightsDisplayContext,
} from '@gitbook/api';
import { DocumentView, DocumentViewSkeleton } from '../DocumentView';
import { CurrentPageProvider } from '../hooks/useCurrentPage';
@@ -127,6 +131,7 @@ export async function PageBody(props: {
ancestors={ancestors}
withRSSFeed={contentHasUpdates}
hasAPIBlocks={hasAPIBlocks}
asEmbeddable={insightsDisplayContext === SiteInsightsDisplayContext.Embed}
/>
{document && !isNodeEmpty(document) ? (
<OptionalSuspense
@@ -29,18 +29,21 @@ interface PageCoverImageProps {
export function PageCoverImage(props: PageCoverImageProps) {
const { imgs, y, height, mask } = props;
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
const { objectPositionY, isLoading } = useCoverPosition(imgs, y, {
height,
aspectRatio: PAGE_COVER_SIZE,
});
if (isLoading) {
return (
<div className="h-full w-full overflow-hidden" ref={containerRef}>
<div className="h-full w-full overflow-hidden">
<div className="h-full w-full animate-pulse bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900" />
</div>
);
}
return (
<div className="h-full w-full overflow-hidden" ref={containerRef} style={{ height }}>
<div className="h-full w-full overflow-hidden @container" style={{ height }}>
<img
src={imgs.light.src}
srcSet={imgs.light.srcSet}
@@ -52,7 +55,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
aspectRatio: height
? undefined
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
objectPosition: `50% ${objectPositionY}%`,
objectPosition: `50% ${objectPositionY}`,
height, // if no height is passed, no height will be set.
maskComposite: 'intersect',
maskImage:
@@ -73,7 +76,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
aspectRatio: height
? undefined
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
objectPosition: `50% ${objectPositionY}%`,
objectPosition: `50% ${objectPositionY}`,
height, // if no height is passed, no height will be set.
maskComposite: 'intersect',
maskImage:
@@ -19,14 +19,17 @@ import {
import { PageAsideToggleButton } from '../PageAside/PageAsideButton';
import { PageIcon } from '../PageIcon';
import { getPDFURLSearchParams } from '../PDF';
import { findBestTargetURL } from '../SiteSections/encodeClientSiteSections';
import {
findBestTargetURL,
getTargetURLForSiteSpace,
} 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, getSiteSpaceURL } from '@/lib/sites';
import { getLocalizedTitle } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { getPageRSSURL } from '@/routes/rss';
@@ -41,8 +44,10 @@ 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 } = props;
const { context, page, ancestors, withRSSFeed, hasAPIBlocks, asEmbeddable } = props;
const { revision, linker } = context;
const hasAncestors = ancestors.length > 0;
@@ -82,7 +87,10 @@ 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) : undefined,
href:
node.object === 'site-section'
? findBestTargetURL(context, node, asEmbeddable)
: undefined,
label: getLocalizedTitle(node, context.locale),
icon: node.icon,
siblings: siblings
@@ -92,7 +100,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);
const href = getSectionNodeURL(context, sibling, asEmbeddable);
// Keep siblings whose URL is "" (the site's first page); only drop the ones
// with no resolvable URL at all.
return href !== undefined
@@ -112,7 +120,7 @@ export async function PageHeader(props: {
if (currentSiteSpace) {
contextCrumbs.push({
key: `variant-${currentSiteSpace.id}`,
href: getSiteSpaceURL(context, currentSiteSpace),
href: getTargetURLForSiteSpace(context, currentSiteSpace, asEmbeddable),
label: getLocalizedTitle(currentSiteSpace, context.locale),
siblings: [],
// Reuse the header's variant switcher rather than plain per-variant links, so each entry
@@ -122,7 +130,7 @@ export async function PageHeader(props: {
slimSpaces: variantSpaces.map((siteSpace) => ({
id: siteSpace.id,
title: getLocalizedTitle(siteSpace, context.locale),
url: getSiteSpaceURL(context, siteSpace),
url: getTargetURLForSiteSpace(context, siteSpace, asEmbeddable),
isActive: siteSpace.id === currentSiteSpace.id,
spaceId: siteSpace.space.id,
})),
@@ -214,9 +222,14 @@ 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: breadcrumb,
page: target,
});
return (
<li key={breadcrumb.id} className="inline">
@@ -448,9 +461,13 @@ 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): string | undefined {
function getSectionNodeURL(
context: GitBookSiteContext,
node: SectionNode,
asEmbeddable: boolean
): string | undefined {
const section = findFirstSection(node);
return section ? findBestTargetURL(context, section) : undefined;
return section ? findBestTargetURL(context, section, asEmbeddable) : undefined;
}
/**
@@ -471,15 +488,16 @@ function getPageSiblings(
if (page.hidden && page.id !== activeId) {
continue;
}
// 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], [])) {
// 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) {
continue;
}
result.push({
id: page.id,
title: page.linkTitle || page.title,
href: context.linker.toPathForPage({ pages: context.revision.pages, page }),
href: context.linker.toPathForPage({ pages: context.revision.pages, page: target }),
emoji: page.emoji,
icon: page.icon,
isActive: page.id === activeId,
@@ -1,6 +1,5 @@
'use client';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useResizeObserver } from 'usehooks-ts';
import { useLayoutEffect, useState } from 'react';
interface ImageSize {
width: number;
@@ -22,19 +21,18 @@ interface Images {
}
/**
* Hook to calculate the object position Y percentage for a cover image
* based on the y offset, image dimensions, and container dimensions.
* Hook to compute the CSS object position Y for a cover image, from the y offset and the image
* dimensions. The container must set `container-type: inline-size` and be as wide as the image,
* since the position is computed against its width.
*/
export function useCoverPosition(imgs: Images, y: number) {
const containerRef = useRef<HTMLDivElement>(null);
export function useCoverPosition(
imgs: Images,
y: number,
container: { height: number | undefined; aspectRatio: ImageSize }
) {
const [loadedDimensions, setLoadedDimensions] = useState<ImageSize | null>(null);
const [isLoading, setIsLoading] = useState(!imgs.light.size && !imgs.dark?.size);
const container = useResizeObserver({
// @ts-expect-error wrong types
ref: containerRef,
});
// Load original image dimensions if not provided in `imgs`
useLayoutEffect(() => {
// Check if we have dimensions from dark (if provided) or else the default light.
@@ -68,42 +66,31 @@ export function useCoverPosition(imgs: Images, y: number) {
// Check dark first, then light, then loaded dimensions
const imageDimensions = imgs.dark?.size ?? imgs.light.size ?? loadedDimensions;
// Calculate ratio and dimensions similar to useCoverPosition hook
const ratio =
imageDimensions && container.height && container.width
? Math.max(
container.width / imageDimensions.width,
container.height / imageDimensions.height
)
: 1;
const safeRatio = ratio || 1;
const scaledHeight =
imageDimensions && container.height ? imageDimensions.height * safeRatio : null;
const maxOffset =
scaledHeight && container.height
? Math.max(0, (scaledHeight - container.height) / 2 / safeRatio)
: 0;
// Parse the position between the allowed min/max
const objectPositionY = useMemo(() => {
if (!container.height || !imageDimensions) {
return 50;
}
const scaled = imageDimensions.height * safeRatio;
if (scaled <= container.height || maxOffset === 0) {
return 50;
}
const clampedOffset = Math.max(-maxOffset, Math.min(maxOffset, y));
const relative = (maxOffset - clampedOffset) / (2 * maxOffset);
return relative * 100;
}, [container.height, imageDimensions, maxOffset, safeRatio, y]);
return {
containerRef,
objectPositionY,
objectPositionY: imageDimensions
? getCoverObjectPositionY(imageDimensions, y, container)
: '50%',
isLoading: !imageDimensions || isLoading,
};
}
/**
* Offset the image `y` natural pixels from centered, clamped so it keeps covering the container.
* Expressed in CSS against the container width (`cqw`), so it renders the same on the server as
* after hydration, without measuring the container.
*/
function getCoverObjectPositionY(
image: ImageSize,
y: number,
container: { height: number | undefined; aspectRatio: ImageSize }
): string {
const containerHeight = container.height
? `${container.height}px`
: `${(100 * container.aspectRatio.height) / container.aspectRatio.width}cqw`;
// Rendered height of the image under `object-fit: cover`.
const scaledHeight = `max(${(100 * image.height) / image.width}cqw, ${containerHeight})`;
const maxOffset = `(${scaledHeight} - ${containerHeight}) / 2`;
const offset = `${scaledHeight} * ${y / image.height}`;
return `calc(50% + clamp(-1 * ${maxOffset}, ${offset}, ${maxOffset}))`;
}
@@ -5,6 +5,7 @@ import React from 'react';
import { useAI } from '../AI';
import { useTrackEvent } from '../Insights';
import { useIntegrationsLoaded } from '../Integrations';
import { useBodyLoaded } from '../primitives';
import {
clearLastSearchQuery,
@@ -140,6 +141,7 @@ export function useSearchController(
const trackEvent = useTrackEvent();
const resultsRef = React.useRef<SearchResultsRef>(null);
const isLoaded = useBodyLoaded();
const integrationsLoaded = useIntegrationsLoaded();
const restoredLastQueryForSiteSpaceRef = React.useRef<string | null>(null);
React.useEffect(() => {
@@ -169,6 +171,8 @@ export function useSearchController(
const withAI = assistants.length > 0;
const withSearchAI = assistants.filter((assistant) => assistant.mode === 'search').length > 0;
// Integration assistants register on window load, so wait for it before treating `ask` as a search query.
const askIsSearchQuery = withSearchAI || (!withAI && integrationsLoaded);
// Handle initial ask state on page load, once assistants are ready.
// `ask=` should still bootstrap the assistant on the docs site, so we must
@@ -203,7 +207,7 @@ export function useSearchController(
const query =
prev?.query ??
getLastSearchQuery(siteSpace.id) ??
(withSearchAI || !withAI ? prev?.ask : null) ??
(askIsSearchQuery ? prev?.ask : null) ??
'';
return {
@@ -217,7 +221,7 @@ export function useSearchController(
trackEvent({
type: 'search_open',
});
}, [state?.open, setSearchState, siteSpace.id, trackEvent, withAI, withSearchAI]);
}, [state?.open, setSearchState, siteSpace.id, trackEvent, withAI, askIsSearchQuery]);
const setQuery = React.useCallback(
(value: string) => {
@@ -261,7 +265,7 @@ export function useSearchController(
});
const searchValue =
state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? lastSearchQuery ?? '';
state?.query ?? (askIsSearchQuery ? state?.ask : null) ?? lastSearchQuery ?? '';
const searchResultsId = `search-results-${React.useId()}`;
// Only clears the remembered last query and stops any in-flight fetch — it must
@@ -82,7 +82,7 @@ type CoverOverlap =
* `data-over-cover="split"` with the crossing point in `--cover-edge`.
*/
function useMarkTextOverCover() {
React.useEffect(() => {
React.useLayoutEffect(() => {
const root = document.documentElement;
const pageCover = document.querySelector<HTMLElement>('[data-gb-page-cover]');
@@ -150,7 +150,7 @@ function useMarkTextOverCover() {
});
};
scheduleUpdate();
update();
window.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate, { passive: true });
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'bun:test';
import type { RevisionPageDocument } from '@gitbook/api';
import { getPageFullTitle } from './title';
import type { GitBookSiteContext } from '@/lib/context';
function makeContext(siteTitle: string, sectionTitle = ''): GitBookSiteContext {
return {
site: { title: siteTitle },
visibleSections: sectionTitle
? {
current: { title: sectionTitle, default: false },
list: [{ object: 'site-section' }, { object: 'site-section' }],
}
: undefined,
visibleSiteSpaces: [],
} as unknown as GitBookSiteContext;
}
function makePage(title: string, tagTitle?: string): RevisionPageDocument {
return { title, tagTitle } as unknown as RevisionPageDocument;
}
describe('getPageFullTitle', () => {
it('uses tagTitle as the first segment', () => {
expect(getPageFullTitle(makeContext('GitBook'), makePage('Page title', 'SEO title'))).toBe(
'SEO title | GitBook'
);
});
it('falls back to the page title when tagTitle is absent', () => {
expect(getPageFullTitle(makeContext('GitBook'), makePage('Page title'))).toBe(
'Page title | GitBook'
);
});
it('deduplicates the section title against tagTitle', () => {
expect(
getPageFullTitle(makeContext('GitBook', 'Section'), makePage('Page title', 'Section'))
).toBe('Section | GitBook');
});
it('deduplicates the site title against tagTitle', () => {
expect(
getPageFullTitle(makeContext('SEO title'), makePage('Page title', 'SEO title'))
).toBe('SEO title');
});
});
@@ -4,7 +4,6 @@ import { notFound, redirect } from 'next/navigation';
import {
CustomizationDefaultThemeMode,
CustomizationHeaderPreset,
type RevisionPageDocument,
SiteInsightsDisplayContext,
type TranslationLanguage,
} from '@gitbook/api';
@@ -18,6 +17,7 @@ import {
getPathnameParam,
} from './fetch';
import { PageClientLayout } from './PageClientLayout';
import { getPageFullTitle } from './title';
import { UpdatesFilterProvider } from '@/components/DocumentView/UpdatesFilter';
import { UpdatesFilterScript } from '@/components/DocumentView/UpdatesFilterScript';
import { PageAside } from '@/components/PageAside';
@@ -33,11 +33,7 @@ import { getResizedImageURL } from '@/lib/images';
import { getPagePath } from '@/lib/pages';
import { resolveContentRef } from '@/lib/references';
import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
import {
getSiteSpacePagePaths,
getSiteStructureTitle,
resolveSiteSpaceCustomHomePage,
} from '@/lib/sites';
import { getSiteSpacePagePaths, resolveSiteSpaceCustomHomePage } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import {
generateUpdatesFilterCSS,
@@ -45,6 +41,8 @@ import {
updatesFilterStyleHref,
} from '@/lib/updates';
export { getPageFullTitle } from './title';
export type SitePageProps = {
context: GitBookSiteContext;
pageParams: PagePathParams;
@@ -286,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.toPathInSpace(pathname));
redirect(context.linker.toPathForPagePath({ path: 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.toPathInSpace('/'));
redirect(context.linker.toPathForPagePath({ path: '/' }));
}
notFound();
}
@@ -421,20 +419,3 @@ async function resolvePageMetaLinks(
alternates: [],
};
}
/**
* Get the <title> for a page.
*/
export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) {
const { site } = context;
const siteStructureTitle = getSiteStructureTitle(context);
return [
page.title,
// Prevent duplicate titles by comparing against the page title.
page.title !== siteStructureTitle ? siteStructureTitle : null, // The first page of a section is often the same as the section title, so we don't need to show it.
page.title !== site.title ? site.title : null, // The site title can also be the same as the site title on the site's landing page.
]
.filter(Boolean)
.join(' | ');
}

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